跟随的眼睛
一双眼睛在页面任何地方都会跟着指针转动,时不时眨一下,增添一点个性。
它们在看着你
用 React 制作跟随指针的一双眼睛,不使用第三方库。每只眼睛是一个 overflow hidden 的白色圆,里面有一个深色瞳孔。在 window 上监听 pointermove;对每只眼睛,用 Math.atan2 计算眼睛中心到指针的角度,并把距离限制在“眼睛半径减瞳孔半径”以内,然后把瞳孔的 translate 设为 (cos × d, sin × d)。用 120ms 的 transform 过渡让它平滑。加上眨眼:一个眼皮在 160ms 内沿 y 轴从 0 放大到 1 再缩回,通过每 4s 一次、在 94% 处触发的 CSS 动画实现,让眨眼显得偶尔发生。组件卸载时移除监听。在 prefers-reduced-motion 下保留瞳孔跟随,去掉眨眼和过渡。提示文字为“它们在看着你”。
源代码
'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; }
}