# Eyes That Follow the Cursor in React and Tailwind

> 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.

- URL: https://qodesign.dev/blog/eyes-follow-cursor-react-tailwind
- Author: Teodor Avadani (https://qodesign.dev/blog/author/teodor-avadani)
- Published: 2026-10-21
- Updated: 2026-10-21
- Category: Animation
- Publisher: qodesign (https://qodesign.dev), a shop of animated React + Tailwind CSS components for Next.js, each sold with its full source code and an AI prompt.

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](/components/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.

## How do eyes follow the cursor?

Each eye is a white circle with a dark pupil inside it. For every eye you need three numbers:

1. The eye's centre, from `getBoundingClientRect()`.
2. The angle from that centre to the cursor, from [`Math.atan2(dy, dx)`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Math/atan2). 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.
3. 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.

## Why CSS variables instead of React state?

`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](/blog/spotlight-effect-react-tailwind-css-mask).

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.

## Finding the text caret in an input

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:

1. Take the text before the caret.
2. Measure its width with a canvas [`measureText()`](https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/measureText) call, using the same font as the input.
3. 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 says](https://developer.mozilla.org/en-US/docs/Web/API/HTMLInputElement/selectionStart) `selectionStart` 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 component

Here's the full sign-in card. The mascot has three moods:

1. watch: the eyes follow the pointer and the email caret.
2. cover: the hands come up over the eyes while the password field has focus.
3. peek: the hands drop a little when the password is shown.

The mood is derived from two booleans, so there's no effect keeping it in sync.

```tsx
"use client";

import { useCallback, useEffect, useId, useRef, useState } from "react";
import { caretPoint } from "./caret";

type Mode = "watch" | "cover" | "peek";

// How far a pupil may travel from the centre of its eye, in px.
const MAX = 6;

const hands: Record<Mode, string> = {
  watch: "translate-y-full",
  cover: "-translate-y-7",
  peek: "-translate-y-4",
};

function aim(face: HTMLElement, x: number, y: number) {
  const eyes = [...face.querySelectorAll<HTMLElement>("[data-eye]")];
  // Read every box first, then write, so the browser lays out once per frame.
  const boxes = eyes.map((eye) => eye.getBoundingClientRect());
  eyes.forEach((eye, i) => {
    const box = boxes[i];
    const dx = x - (box.left + box.width / 2);
    const dy = y - (box.top + box.height / 2);
    const angle = Math.atan2(dy, dx);
    const reach = Math.min(MAX, Math.hypot(dx, dy) / 20);
    eye.style.setProperty("--px", `${Math.cos(angle) * reach}px`);
    eye.style.setProperty("--py", `${Math.sin(angle) * reach}px`);
  });
}

export default function ShyLogin({
  action,
}: {
  action: (formData: FormData) => void | Promise<void>;
}) {
  const id = useId();
  const face = useRef<HTMLDivElement>(null);
  const target = useRef({ x: 0, y: 0 });
  const frame = useRef(0);
  const [passwordFocused, setPasswordFocused] = useState(false);
  const [show, setShow] = useState(false);

  const mode: Mode = passwordFocused ? (show ? "peek" : "cover") : "watch";

  // One write per animation frame, however many events arrive.
  const lookAt = useCallback((x: number, y: number) => {
    target.current = { x, y };
    if (frame.current) return;
    frame.current = requestAnimationFrame(() => {
      frame.current = 0;
      if (face.current) aim(face.current, target.current.x, target.current.y);
    });
  }, []);

  useEffect(() => {
    const onMove = (e: PointerEvent) => lookAt(e.clientX, e.clientY);
    window.addEventListener("pointermove", onMove, { passive: true });
    return () => {
      window.removeEventListener("pointermove", onMove);
      cancelAnimationFrame(frame.current);
      frame.current = 0;
    };
  }, [lookAt]);

  const follow = (input: HTMLInputElement) => {
    const point = caretPoint(input);
    lookAt(point.x, point.y);
  };

  return (
    <form action={action} className="mx-auto w-full max-w-sm space-y-4 rounded-2xl bg-zinc-900 p-6 text-zinc-100">
      <div ref={face} aria-hidden="true" className="relative mx-auto h-28 w-28 overflow-hidden rounded-full bg-amber-300">
        <div className="absolute inset-x-0 top-9 flex justify-center gap-5">
          {[0, 1].map((i) => (
            <span key={i} data-eye className="grid h-7 w-7 place-items-center rounded-full bg-white">
              <span className="h-3 w-3 translate-x-(--px) translate-y-(--py) rounded-full bg-zinc-900 motion-reduce:translate-none" />
            </span>
          ))}
        </div>
        <div
          className={`absolute inset-x-0 bottom-0 flex justify-center gap-3 transition-transform duration-300 ease-out motion-reduce:transition-none ${hands[mode]}`}
        >
          <span className="h-14 w-12 rounded-t-full bg-amber-500" />
          <span className="h-14 w-12 rounded-t-full bg-amber-500" />
        </div>
      </div>

      <div>
        <label htmlFor={`${id}-email`} className="text-sm">Email</label>
        <input
          id={`${id}-email`}
          name="email"
          type="email"
          autoComplete="email"
          required
          onFocus={(e) => follow(e.currentTarget)}
          onInput={(e) => follow(e.currentTarget)}
          className="mt-1 block w-full rounded-lg bg-zinc-800 px-3 py-2 outline-none focus-visible:ring-2 focus-visible:ring-amber-300"
        />
      </div>

      <div>
        <label htmlFor={`${id}-password`} className="text-sm">Password</label>
        <div className="mt-1 flex rounded-lg bg-zinc-800 focus-within:ring-2 focus-within:ring-amber-300">
          <input
            id={`${id}-password`}
            name="password"
            type={show ? "text" : "password"}
            autoComplete="current-password"
            required
            onFocus={() => setPasswordFocused(true)}
            onBlur={() => setPasswordFocused(false)}
            className="block w-full bg-transparent px-3 py-2 outline-none"
          />
          <button
            type="button"
            aria-label="Show password"
            aria-pressed={show}
            onMouseDown={(e) => e.preventDefault()}
            onClick={() => setShow((s) => !s)}
            className="px-3 text-xs text-zinc-400 hover:text-zinc-100"
          >
            {show ? "Hide" : "Show"}
          </button>
        </div>
      </div>

      <button type="submit" className="w-full rounded-lg bg-amber-300 py-2 font-medium text-zinc-900">
        Sign in
      </button>
    </form>
  );
}
```

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.

## Using it on a Next.js sign-in page

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.

## Is a login mascot bad for accessibility?

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](/blog/accessible-animated-login-form-react).

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](/blog/prefers-reduced-motion-react).

On phones there's usually no hovering pointer, so the eyes mostly follow the caret, which is the part people actually notice.

## Bugs to check for in AI-generated eye tracking code

If you ask Cursor, v0 or Lovable for this, these are the things to check before you ship it:

1. Pupil position kept in `useState` and set on every `pointermove`. It works, but the whole form re-renders on every mouse move.
2. 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.
3. One angle computed from the face's centre and used for both eyes. The eyes look parallel and never converge on the cursor.
4. A `pointermove` listener added in an effect with no cleanup, so it stacks up every time the component mounts again.
5. 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."

## The full version

Our [Shy Eyes Login](/components/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](/react/animated-login-form-mascot) page. If you want to try the catalogue first, one piece is [free](/free).

## FAQ

### How do I make eyes follow the mouse in React?

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`.

### Can I do this with only CSS?

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.

### Why is selectionStart null on my email input?

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`.

### Do I need Framer Motion for this?

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.
