TL;DR Compute Math.atan2 from each eye's own centre to the cursor, clamp the pupil to a few pixels, and write the offset to CSS variables with style.setProperty once per requestAnimationFrame instead of React state. To follow the text caret, measure the text before it with canvas measureText, and fall back to value.length because selectionStart is null on type=email inputs.
Eyes that follow the cursor are two pupils that turn toward the mouse, and on a login form they can also follow the text caret while someone types. In React and Tailwind it takes about 40 lines: Math.atan2 for the direction, a clamp so the pupil stays inside the eye, two CSS variables written straight to the DOM once per animation frame, and a small canvas measure to find the caret. No library and no re-render per mouse move.
I make qodesign, a set of animated React + TypeScript + Tailwind components. Our Shy Eyes Login is a sign-in card with a round mascot whose eyes follow your pointer and your caret, who covers his eyes for the password and peeks through his fingers when you show it. The component itself uses Framer Motion. The code below is a smaller version written for this post. It uses only React and Tailwind, and it compiles with TypeScript in strict mode against React 19 and Next.js 15.
Each eye is a white circle with a dark pupil inside it. For every eye you need three numbers:
The eye's centre, from getBoundingClientRect().
The angle from that centre to the cursor, from Math.atan2(dy, dx). It returns the angle in radians, and it handles every quadrant for you, so you don't need any if branches for left, right, up or down.
How far to move the pupil. Use Math.cos(angle) and Math.sin(angle) times a distance that never goes past the edge of the eye.
That last clamp is what makes it look like eyes. Without it the pupil slides out of the white and the face looks broken. I cap it at 6 px for a 28 px eye, and I scale it down when the cursor is close, so the pupil drifts toward the centre when you hover right over the face instead of snapping to the rim.
Do the math per eye, not once for the face. When each eye aims at the same point from its own centre, both eyes converge on the cursor when it's close, which is what real eyes do.
Shy Eyes Login running live. A login mascot that watches you type and covers his eyes for the password.
pointermove can fire many times per frame. If each event calls setState, React re-renders the whole login form for every pixel the mouse moves, and you'll feel it on a slow laptop.
So the pointer handler only stores the latest position in a ref and asks for one requestAnimationFrame. When the frame runs, it reads the eye boxes, works out the offsets and writes them with style.setProperty("--px", ...). In Tailwind v4, translate-x-(--px) reads that variable directly, so the pupil moves with zero React work. I used the same trick for the moving light in the spotlight effect post.
One more detail: read every eye's box first, then write. If you read one, write one, read the next, the browser can be forced to recalculate layout in between.
Following the caret is what makes a login mascot feel alive. While you type your email, the eyes walk along the text.
The browser has no API that gives you the caret's pixel position in an <input>. You can work it out:
Take the text before the caret.
Measure its width with a canvas measureText() call, using the same font as the input.
Add the input's left edge, border and padding, and subtract scrollLeft for long values that have scrolled.
There's a catch, and it bites every login form. MDN saysselectionStart only applies to inputs of type text, search, url, tel and password. On a type="email" input it reads null. Code that does value.slice(0, input.selectionStart) quietly measures the wrong thing. The fix is a fallback to value.length. While someone types an email the caret is almost always at the end anyway, so it looks right.
You could switch the field to type="text" with inputMode="email" to get the real caret, but you'd lose the browser's built-in email check. I'd keep type="email" and take the fallback.
ts
let canvas: HTMLCanvasElement | null = null;// Where the text caret sits inside an input, in viewport pixels.export function caretPoint(input: HTMLInputElement) { const style = getComputedStyle(input); const box = input.getBoundingClientRect(); const y = box.top + box.height / 2; canvas ??= document.createElement("canvas"); const ctx = canvas.getContext("2d"); if (!ctx) return { x: box.left + box.width / 2, y }; // Build the font from its parts so the measure matches what the input draws. ctx.font = `${style.fontStyle} ${style.fontWeight} ${style.fontSize} ${style.fontFamily}`; // type="email" has no selectionStart (it reads null), so assume the caret is at the end. const caret = input.selectionStart ?? input.value.length; const width = ctx.measureText(input.value.slice(0, caret)).width; const start = box.left + parseFloat(style.borderLeftWidth) + parseFloat(style.paddingLeft); const end = box.right - parseFloat(style.borderRightWidth) - parseFloat(style.paddingRight); return { x: Math.min(start + width - input.scrollLeft, end), y };}
Build the font string from its parts (fontStyle, fontWeight, fontSize, fontFamily) so the measure uses the same font the input draws. If you add letter spacing to the input with a tracking-* class, this measure ignores it and the eyes will drift ahead of the caret on long emails, so keep the input's tracking normal.
The show/hide button calls preventDefault() on mousedown. Without it, clicking the button moves focus off the password field, the hands drop for a split second and then fly back up, and the mascot looks like it's twitching.
The card is a Client Component, because it listens to the pointer and reads the DOM. The page around it can stay a Server Component and pass a Server Action as the form's action:
tsx
import { redirect } from "next/navigation";import ShyLogin from "./ShyLogin";async function signIn(formData: FormData) { "use server"; const email = formData.get("email"); const password = formData.get("password"); if (typeof email !== "string" || typeof password !== "string") return; // Check the pair with your auth provider here. redirect("/dashboard");}export default function SignInPage() { return ( <main className="grid min-h-dvh place-items-center bg-zinc-950 p-6"> <ShyLogin action={signIn} /> </main> );}
The mascot has no effect on what gets submitted. The form is a normal form with labels, autoComplete values and required, so password managers and browser autofill work as usual.
Not if it stays decoration. The face has aria-hidden="true", so screen readers skip it and only hear the labels, fields and buttons. Nothing important is shown only by the mascot. Errors still belong in text next to the field, as in the accessible login form post.
Motion is the other part. motion-reduce:translate-none keeps the pupils centred when someone has reduce motion turned on, and motion-reduce:transition-none makes the hands switch state without sliding. More on that pattern in prefers-reduced-motion in React.
On phones there's usually no hovering pointer, so the eyes mostly follow the caret, which is the part people actually notice.
If you ask Cursor, v0 or Lovable for this, these are the things to check before you ship it:
Pupil position kept in useState and set on every pointermove. It works, but the whole form re-renders on every mouse move.
Caret math that reads selectionStart on a type="email" input with no fallback. It's null there, so the eyes jump to the start of the field.
One angle computed from the face's centre and used for both eyes. The eyes look parallel and never converge on the cursor.
A pointermove listener added in an effect with no cleanup, so it stacks up every time the component mounts again.
No aria-hidden on the face, so a screen reader announces a pile of empty elements before the email field.
A prompt that avoids all five:
"Build a React + Tailwind v4 login card with a mascot whose pupils follow the pointer and the email input's caret. Store the pointer position in a ref and update CSS variables --px and --py once per requestAnimationFrame with style.setProperty, never useState. Compute the angle with Math.atan2 from each eye's own center and clamp the pupil to 6 px. Find the caret with canvas measureText and fall back to value.length when selectionStart is null (it is null on type=email). Cover the eyes with two hands while the password field is focused. Mark the mascot aria-hidden, clean up listeners on unmount, and keep pupils centred under motion-reduce."
Our Shy Eyes Login goes further than this post: the eyes follow the pointer and the caret, he covers his eyes and blushes on the password, peeks through his fingers when you show it, and there are validation and success states. It ships as a single HTML file and as a React + TypeScript + Tailwind version, with a PROMPT.md for your AI editor. There's a guide for dropping it into a Next.js app on the login form with a shy mascot page. If you want to try the catalogue first, one piece is free.
Listen to pointermove on the window, keep the latest position in a ref, and once per requestAnimationFrame compute Math.atan2(dy, dx) from each eye's centre. Move the pupil by cos and sin of that angle times a clamped distance, written to CSS variables with style.setProperty.
Not the following part. CSS can't read the cursor position on its own, so you need a few lines of JavaScript to write the position somewhere. Everything after that, moving the pupil and sliding the hands, is plain Tailwind classes.
The spec only exposes the selection on text, search, url, tel and password inputs. On type="email" it reads null, and setting it throws. Fall back to value.length.
No. Moving a pupil is a translate change driven by your own math, and the hands only move between fixed positions, which a CSS transition handles. Framer Motion helps when you want spring physics or a sequence of poses, which is why our full component uses it.