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
- Each eye computes its own vector to the pointer from its own centre — not one shared vector — so the two pupils converge slightly on a nearby cursor, which is what makes it read as looking at rather than sliding with. Frame-diffing the source: both pupils sit hard against the same edge of the socket whenever the cursor is far, and cross when it is between them.
- The vector is divided by a reach of 260px and clamped to the unit disc, then written as two custom properties; CSS turns them into
translate(calc(var(--px) * 14px), calc(var(--py) * 12px)). The clamp is what keeps the pupil inside the white — without it a far cursor drags it out through the lid. - Three layers of lag: pupils 160ms, brows 320ms, head 700ms, all the same ease-out. The eyes snap, the face follows. One transition speed for everything reads as a rigid mask sliding.
- Brows do not translate — they rotate ±6° with
--pxand lift 3px with--py. Looking up raises the brows; looking sideways tilts them. Both are the same two numbers the pupils use. - The blink is the socket's own colour sliding down as a lid: a 180ms keyframe on
translateY, both eyes on the same timer, every 3–6 seconds. Restarting it isclassList.remove → void offsetWidth → classList.add, the standard replay-a-CSS-animation dance. - Fur is an SVG
feTurbulence + feDisplacementMapfilter (frequency .9, scale 9) applied to three plain rounded shapes. It costs one filter pass on layout, and nothing on pointer move, because the shapes never change — only the small elements inside them transform. - When the pointer leaves the document (
mouseoutwithrelatedTarget === null) the creature looks back at the reader: the vector is reset to the page centre rather than left wherever the cursor was last seen.
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.
<!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
- Computing the vector from
getBoundingClientRect()on every pointermove is two layout reads per event. Fine here; with twenty faces on a page cache the centres and refresh on resize. - A filter on a large element is rasterised at its full size once. Animating anything inside a filtered element re-runs the filter; here the eyes sit beside the filtered shapes, not inside them, and that is deliberate.
- On touch there is no hover: the eyes only move on a tap. Either accept a still face on phones, or drive the gaze from device orientation instead.
Seen in
@优卓UX上岸社 (Douyin) · 「UI交互动效描述」第 4 集 · Logoipsum · 0:00