Swish UI
All componentsCursor Free

Eyes that follow

A small character whose pupils watch the pointer anywhere on the page and who blinks every few seconds.

It imports two small hooks from @/lib/motion and the colour tokens; the zip includes both.

Demo.tsx

"use client";
import { useEffect, useRef, useState } from "react";
import { usePrefersReducedMotion } from "@/lib/motion";
import styles from "./demo.module.css";

export default function Demo() {
  const root = useRef<HTMLDivElement>(null);
  const face = useRef<HTMLButtonElement>(null);
  const eyes = useRef<(HTMLSpanElement | null)[]>([]);
  const pupils = useRef<(HTMLSpanElement | null)[]>([]);
  const reduced = usePrefersReducedMotion();
  const [hurt, setHurt] = useState(false);
  const hurtTimer = useRef(0);
  useEffect(() => () => clearTimeout(hurtTimer.current), []);
  // A poke makes the character flinch and protest for a moment.
  const poke = () => {
    setHurt(true);
    clearTimeout(hurtTimer.current);
    hurtTimer.current = window.setTimeout(() => setHurt(false), 1600);
  };

  useEffect(() => {
    // Elements are read once here: a frame or timer can still run after unmount, when refs are already null.
    const [el, faceEl] = [root.current, face.current];
    const pupilEls = [...pupils.current];
    if (!el || !faceEl) return;
    const targets = [{ x: 0, y: 0 }, { x: 0, y: 0 }];
    const current = [{ x: 0, y: 0 }, { x: 0, y: 0 }];
    let raf = 0, blinkTimer = 0;

    const render = () => {
      current.forEach((c, i) => {
        c.x += (targets[i].x - c.x) * (reduced ? 1 : 0.2);
        c.y += (targets[i].y - c.y) * (reduced ? 1 : 0.2);
        pupilEls[i]!.style.transform = `translate(${c.x}px, ${c.y}px)`;
      });
      const moving = current.some((c, i) => Math.hypot(targets[i].x - c.x, targets[i].y - c.y) > 0.05);
      raf = moving ? requestAnimationFrame(render) : 0;
    };
    // Each pupil aims at the point independently, capped to stay inside its eye.
    const look = (clientX: number, clientY: number) => {
      eyes.current.forEach((eye, i) => {
        const rect = eye!.getBoundingClientRect();
        const dx = clientX - (rect.left + rect.width / 2);
        const dy = clientY - (rect.top + rect.height / 2);
        const distance = Math.hypot(dx, dy) || 1;
        const reach = Math.min(rect.width * 0.26, distance * 0.2);
        targets[i] = { x: (dx / distance) * reach, y: (dy / distance) * reach };
      });
      if (!raf) raf = requestAnimationFrame(render);
    };
    const move = (event: PointerEvent) => {
      if (event.pointerType !== "touch") look(event.clientX, event.clientY);
    };
    // Touch: the eyes turn to the last tap and stay there.
    const down = (event: PointerEvent) => {
      if (event.pointerType === "touch") look(event.clientX, event.clientY);
    };
    const leave = (event: PointerEvent) => {
      if (event.pointerType === "touch") return;
      targets.forEach((t) => Object.assign(t, { x: 0, y: 0 }));
      if (!raf) raf = requestAnimationFrame(render);
    };
    // Blinks land at uneven intervals so they read as alive, not mechanical.
    const blink = () => {
      faceEl.classList.add(styles.blink);
      window.setTimeout(() => faceEl.classList.remove(styles.blink), 160);
      blinkTimer = window.setTimeout(blink, 2400 + Math.random() * 2600);
    };
    if (!reduced) blinkTimer = window.setTimeout(blink, 1600);

    // The eyes watch the whole page, and look ahead again when the pointer leaves the window.
    const page = document.documentElement;
    page.addEventListener("pointermove", move);
    page.addEventListener("pointerdown", down);
    page.addEventListener("pointerleave", leave);
    return () => {
      cancelAnimationFrame(raf);
      clearTimeout(blinkTimer);
      page.removeEventListener("pointermove", move);
      page.removeEventListener("pointerdown", down);
      page.removeEventListener("pointerleave", leave);
    };
  }, [reduced]);

  return (
    <div ref={root} className={styles.root}>
      <div className={styles.buddy}>
        <p className={styles.bubble} aria-live="polite">{hurt ? "My eyes are real, please don’t hit me!" : "I see you"}</p>
        <button ref={face} type="button" className={`${styles.face} ${hurt ? styles.hurt : ""}`} aria-label="Poke" onClick={poke}>
          {[0, 1].map((i) => (
            <span key={i} ref={(node) => { eyes.current[i] = node; }} className={styles.eye}>
              <span ref={(node) => { pupils.current[i] = node; }} className={styles.pupil} />
            </span>
          ))}
          <span className={styles.mouth} />
        </button>
      </div>
    </div>
  );
}

demo.module.css

.root { width: 100%; height: 100%; display: grid; place-items: center; overflow: hidden; container-type: size; touch-action: manipulation; }
.buddy { display: grid; justify-items: center; gap: 14px; }
.bubble { position: relative; max-width: min(90cqi, 240px); text-align: center; text-wrap: balance; padding: 8px 14px; border-radius: 14px; background: var(--white); border: 1px solid var(--line); font-size: 14px; font-weight: 600; }
.bubble::after { content: ""; position: absolute; left: 50%; bottom: -6px; width: 10px; height: 10px; translate: -50% 0; rotate: 45deg; background: var(--white); border: solid var(--line); border-width: 0 1px 1px 0; }
.face {
  --s: clamp(120px, 52cqh, 220px);
  position: relative; width: var(--s); height: calc(var(--s) * .82); display: flex; justify-content: center; align-items: center; gap: calc(var(--s) * .08);
  padding-bottom: calc(var(--s) * .12); border-radius: 46% 46% 40% 40%; background: var(--accent);
  box-shadow: inset 0 calc(var(--s) * -.06) 0 var(--accent-text);
  border: 0; font: inherit; cursor: pointer; -webkit-tap-highlight-color: transparent;
}
.face:focus-visible { outline: 3px solid var(--accent-text); outline-offset: 4px; }
.eye {
  position: relative; width: calc(var(--s) * .27); aspect-ratio: 1; display: grid; place-items: center;
  border-radius: 50%; background: var(--white); transition: scale .08s;
}
.pupil { width: 44%; aspect-ratio: 1; border-radius: 50%; background: var(--ink); }
.pupil::after { content: ""; display: block; width: 32%; aspect-ratio: 1; margin: 18% 0 0 22%; border-radius: 50%; background: var(--white); }
.blink .eye, .hurt .eye { scale: 1 .08; }
.hurt { animation: flinch .4s var(--out); }
@keyframes flinch { 20% { translate: -6px 0; rotate: -4deg; } 45% { translate: 5px 0; rotate: 3deg; } 70% { translate: -2px 0; rotate: -1deg; } }
.mouth { position: absolute; left: 50%; bottom: 20%; width: calc(var(--s) * .16); height: calc(var(--s) * .07); translate: -50% 0; border-radius: 0 0 999px 999px; background: var(--accent-text); }
@media (prefers-reduced-motion: reduce) { .eye { transition: none; } .hurt { animation: none; } }

Use it for

  • Empty states, 404 pages and onboarding mascots
  • Friendly brands that use a character in their voice

Avoid

  • Serious contexts like billing or errors that cost money
  • Next to forms where the character distracts from the task

How it works

A pointermove listener on the document (so the eyes watch the whole page, not just their box) takes the vector from each eye centre to the pointer is normalised and scaled by a reach that grows with distance but is capped at a quarter of the eye, so each pupil aims on its own and the pair converges when the pointer is close. A requestAnimationFrame loop eases the pupils toward their targets and stops when they settle. A self rescheduling timeout adds a blink class at random intervals between 2.4 and 5 seconds.

Accessibility

The face is a button labelled Poke: clicking it makes the character flinch and ask you not to hit it, and the speech bubble is a polite live region so the line is announced. Touch users make the eyes look at the last tap. Reduced motion skips the blink and the easing, so pupils jump straight to where they look.

Found on