Web design effects

Cursor-driven gaze tracking

A plush mascot whose eyes follow the cursor anywhere on the page — pupils clamped to their sockets, brows leaning a beat later, the whole head tilting a touch, and a blink every few seconds.

How it works

Numbers

Reach
260px, clamped to the unit disc
Pupil travel
±14px x · ±12px y
Lag
pupils 160ms · brows 320ms · head 700ms
Brow
rotate ±6° · lift 3px
Head
rotate ±2.5° · shift ±6px
Blink
180ms every 3–6s
Fur
feTurbulence .9 · displacement 9

Prompt

Paste this into any coding model to get the effect from scratch. It states every number explicitly — vague words like “smooth” or “modern” produce a different result every time.

Build a cursor-driven gaze-tracking mascot in vanilla HTML/CSS/JS. Pale lavender page with small nav copy in the corners and a script logo at the top. A large purple blob rises from the bottom (border-radius blob, radial-gradient shading) with a pink plush mouth shape and a lip shape; apply an SVG filter (feTurbulence type fractalNoise baseFrequency .9 numOctaves 2 → feDisplacementMap scale 9) to those three shapes for a felt edge. Two white eye sockets with overflow hidden, each containing a 30px pupil (radial gradient with a highlight) and a lid element the same colour as the fur. On pointermove, for EACH eye separately: vector = (cursor − eye centre) / 260, clamp to the unit disc, write --px and --py on the eye and on its brow. Pupils use transform: translate(calc(var(--px)*14px), calc(var(--py)*12px)) with transition 160ms cubic-bezier(.22,.61,.36,1); brows use rotate(calc(var(--px)*6deg)) translateY(calc(var(--py)*-3px)) with 320ms; the whole creature rotates ±2.5° and shifts ±6px from a --tx derived from cursor x, with 700ms. On a document-level mouseout whose relatedTarget is null, look at the page centre. Blink both eyes together every 3–6 s with a 180ms keyframe that slides the lid from translateY(-100%) to 0 and back, restarted by removing the class, reading offsetWidth, and adding it again. Under prefers-reduced-motion drop the transitions and the blink.

Source

The whole file. It is what the sample above is running — copy it into an .html file and it works with nothing else.

cursor-gaze-tracking.html
<!doctype html>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Cursor-driven gaze tracking</title>
<style>
  * { margin: 0; padding: 0; box-sizing: border-box; }
  html, body { height: 100%; }
  body {
    font-family: -apple-system, BlinkMacSystemFont, "SF Pro Display", sans-serif;
    background: #ece8f7; color: #1a1620; overflow: hidden; position: relative;
    -webkit-user-select: none; user-select: none;
  }
  .corner { position: absolute; top: 18px; font-size: 7px; letter-spacing: .02em; color: #1a162099; line-height: 1.6; }
  .corner b { display: block; font-size: 11px; color: #1a1620; line-height: 1.2; margin: 2px 0 6px; }
  .corner.l { left: 26px; } .corner.r { right: 26px; text-align: right; }
  .logo { position: absolute; left: 50%; top: 16px; transform: translateX(-50%); font: italic 700 18px/1 "Snell Roundhand", "Brush Script MT", cursive; letter-spacing: .02em; }
  .menu { margin-top: 10px; font-size: 8px; line-height: 1.9; color: #1a1620; }

  /* ---------- the creature ----------
     One big blob, fuzzy at the edge (an SVG turbulence displacement on the
     silhouette), a plush mouth, two eyes. Everything that moves is a transform
     on a small element; the fur never re-renders. */
  .crit {
    position: absolute; left: 50%; bottom: -80px; width: 460px; height: 420px; margin-left: -230px;
    transform: rotate(calc(var(--tx, 0) * 2.5deg)) translateX(calc(var(--tx, 0) * 6px));
    transition: transform 700ms cubic-bezier(.22,.61,.36,1);
  }
  .fur {
    position: absolute; inset: 0; border-radius: 48% 52% 40% 40% / 60% 60% 40% 40%;
    background: radial-gradient(70% 60% at 45% 30%, #d9cdf2, #b9a8e6 60%, #a08cd6);
    filter: url(#fuzz);
  }
  .mouth {
    position: absolute; left: 50%; top: 150px; width: 250px; height: 130px; margin-left: -125px;
    border-radius: 50% 50% 45% 45% / 60% 60% 40% 40%;
    background: radial-gradient(60% 55% at 40% 35%, #f3a6c4, #e07aa2 60%, #c95f8a);
    filter: url(#fuzz);
  }
  .mouth::after { content: ""; position: absolute; left: 50%; top: 0; width: 60px; height: 24px; margin-left: -30px; border-radius: 0 0 30px 30px; background: #6b3b57; }
  .lip { position: absolute; left: 50%; top: 262px; width: 180px; height: 40px; margin-left: -90px; border-radius: 0 0 50% 50% / 0 0 100% 100%; background: #b9a8e6; filter: url(#fuzz); }

  .eye {
    position: absolute; top: 74px; width: 68px; height: 62px; border-radius: 50%;
    background: radial-gradient(circle at 40% 35%, #fff, #e9e6f2 80%);
    box-shadow: inset 0 -3px 6px #0000001f, 0 2px 4px #00000014;
    overflow: hidden;
  }
  .eye.l { left: 158px; } .eye.r { left: 236px; }
  /* the pupil is the only thing JS touches: two custom properties, clamped to the socket */
  .pupil {
    position: absolute; left: 50%; top: 50%; width: 30px; height: 30px; margin: -15px 0 0 -15px; border-radius: 50%;
    background: radial-gradient(circle at 35% 30%, #4a4458 0 18%, #14111c 20%);
    transform: translate(calc(var(--px, 0) * 14px), calc(var(--py, 0) * 12px));
    transition: transform 160ms cubic-bezier(.22,.61,.36,1);
  }
  .pupil::after { content: ""; position: absolute; left: 6px; top: 5px; width: 7px; height: 5px; border-radius: 50%; background: #ffffffd9; }
  /* blink: the lid is the socket's own background sliding down */
  .lid { position: absolute; inset: 0; background: #b9a8e6; transform: translateY(-100%); border-radius: 50%; }
  .blink .lid { animation: blink 180ms ease-in-out; }
  @keyframes blink { 0%, 100% { transform: translateY(-100%); } 50% { transform: translateY(0); } }
  .brow {
    position: absolute; top: 58px; width: 34px; height: 12px; border-radius: 50% 50% 0 0 / 100% 100% 0 0;
    border-top: 6px solid #f0b46a; border-radius: 40px 40px 0 0;
    /* brows lean the way the eyes look, a few degrees, later than the pupils */
    transform: rotate(calc(var(--px, 0) * 6deg)) translateY(calc(var(--py, 0) * -3px));
    transition: transform 320ms cubic-bezier(.22,.61,.36,1);
  }
  .brow.l { left: 166px; } .brow.r { left: 258px; }

  .hint { position: absolute; left: 0; right: 0; bottom: 12px; text-align: center; font-size: 10px; letter-spacing: .18em; text-transform: uppercase; color: #1a162066; }
  @media (prefers-reduced-motion: reduce) { .crit, .pupil, .brow { transition: none; } .blink .lid { animation: none; } .hint { display: none; } }
</style>

<svg width="0" height="0" style="position:absolute" aria-hidden="true">
  <filter id="fuzz" x="-10%" y="-10%" width="120%" height="120%">
    <feTurbulence type="fractalNoise" baseFrequency=".9" numOctaves="2" seed="4" result="n"/>
    <feDisplacementMap in="SourceGraphic" in2="n" scale="9" xChannelSelector="R" yChannelSelector="G"/>
  </filter>
</svg>

<div class="corner l">have a fresh idea?<b>imagination<br>meets craft</b><div class="menu">Made<br>Story<br>In the lab<br>Say hey</div></div>
<div class="logo">Logoipsum</div>
<div class="corner r">say hey<b>let's team up!<br>bring us your idea*</b>*good things start with one spark. let's make yours.</div>

<div class="crit" id="crit">
  <div class="fur"></div>
  <div class="mouth"></div>
  <div class="lip"></div>
  <div class="brow l"></div><div class="brow r"></div>
  <div class="eye l"><div class="pupil"></div><div class="lid"></div></div>
  <div class="eye r"><div class="pupil"></div><div class="lid"></div></div>
</div>
<div class="hint">move the cursor · it watches</div>

<script>
  /* ---------- gaze ----------
     Each eye works out its own vector to the cursor, normalised against a
     reach of 260px, then clamped to the unit disc so the pupil never leaves
     the socket. Two custom properties per eye; CSS turns them into the
     translate and the transition does the easing. Nothing else is written. */
  const crit = document.getElementById("crit");
  const eyes = [...document.querySelectorAll(".eye")];
  const brows = [...document.querySelectorAll(".brow")];
  const reduce = matchMedia("(prefers-reduced-motion: reduce)").matches;
  const REACH = 260;

  function look(x, y) {
    eyes.forEach((eye, i) => {
      const r = eye.getBoundingClientRect();
      let dx = (x - (r.left + r.width / 2)) / REACH, dy = (y - (r.top + r.height / 2)) / REACH;
      const m = Math.hypot(dx, dy); if (m > 1) { dx /= m; dy /= m; }
      eye.style.setProperty("--px", dx.toFixed(3)); eye.style.setProperty("--py", dy.toFixed(3));
      brows[i].style.setProperty("--px", dx.toFixed(3)); brows[i].style.setProperty("--py", dy.toFixed(3));
    });
    // the whole head leans a touch towards the pointer, on a slower spring
    crit.style.setProperty("--tx", ((x / innerWidth) * 2 - 1).toFixed(3));
  }
  addEventListener("pointermove", (e) => look(e.clientX, e.clientY), { passive: true });
  // pointer gone: look back at the reader
  document.addEventListener("mouseout", (e) => { if (!e.relatedTarget) look(innerWidth / 2, innerHeight * 0.45); });
  look(innerWidth / 2, innerHeight * 0.45);

  // blink every 3–6 s, both eyes together, never while reduced-motion
  if (!reduce) (function blink() {
    setTimeout(() => {
      eyes.forEach((e) => { e.classList.remove("blink"); void e.offsetWidth; e.classList.add("blink"); });
      blink();
    }, 3000 + Math.random() * 3000);
  })();
</script>

Where it breaks

Seen in

@优卓UX上岸社 (Douyin) · 「UI交互动效描述」第 4 集 · Logoipsum · 0:00