Following Eyes
A pair of eyes tracks the pointer anywhere on the page and blinks now and then, adding a little personality.
Create a pair of eyes that follow the pointer in React, no libraries. Each eye is a white circle with overflow hidden and a dark pupil inside. Listen to pointermove on window; for each eye, compute the angle from the eye centre to the pointer with Math.atan2 and a distance clamped to the eye radius minus the pupil radius, then set the pupil translate to (cos × d, sin × d). Smooth it with a 120ms transform transition. Add a blink: a lid that scales on the y axis from 0 to 1 and back in 160ms, triggered by a CSS animation every 4s at the 94% mark so it feels occasional. Remove the listener on unmount. Under prefers-reduced-motion, keep the pupils tracking but drop the blink and the transition. The hint reads "They are watching".
Source
'use client'
import { useEffect, useRef } from 'react'
import s from './demo.module.css'
const EYE = 28
const PUPIL = 11
export default function FollowingEyes({ labels }: { labels: Record<string, string> }) {
const eyes = useRef<(HTMLSpanElement | null)[]>([])
useEffect(() => {
const onMove = (e: PointerEvent) => {
for (const eye of eyes.current) {
if (!eye) continue
const r = eye.getBoundingClientRect()
const dx = e.clientX - (r.left + r.width / 2)
const dy = e.clientY - (r.top + r.height / 2)
const a = Math.atan2(dy, dx)
const d = Math.min(Math.hypot(dx, dy) / 8, EYE - PUPIL - 4)
;(eye.firstElementChild as HTMLElement).style.translate = `${Math.cos(a) * d}px ${Math.sin(a) * d}px`
}
}
window.addEventListener('pointermove', onMove)
return () => window.removeEventListener('pointermove', onMove)
}, [])
return (
<div className={s.wrap}>
<div className={s.face} aria-hidden="true">
{[0, 1].map((i) => (
<span key={i} ref={(el) => void (eyes.current[i] = el)} className={s.eye}>
<span className={s.pupil} />
<span className={s.lid} />
</span>
))}
</div>
<span className={s.hint}>{labels.hint}</span>
</div>
)
}
.wrap {
display: grid;
justify-items: center;
gap: 16px;
}
.face {
display: flex;
gap: 14px;
}
.eye {
position: relative;
display: grid;
place-items: center;
width: 56px;
height: 56px;
overflow: hidden;
border-radius: 50%;
background: var(--stage-ink);
}
.pupil {
width: 22px;
height: 22px;
border-radius: 50%;
background: var(--stage-bg);
box-shadow: inset 4px -4px 0 -1px var(--stage-accent);
transition: translate 120ms var(--ease-out);
}
.lid {
position: absolute;
inset: 0;
background: var(--stage-accent);
transform: scaleY(0);
transform-origin: top;
animation: blink 4s infinite;
}
@keyframes blink {
0%, 92%, 100% { transform: scaleY(0); }
94% { transform: scaleY(1); }
}
.hint {
font: 500 var(--text-xs) / 1 var(--font-mono);
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--stage-muted);
}
@media (prefers-reduced-motion: reduce) {
.lid { animation: none; }
.pupil { transition: none; }
}