107 lines
3.1 KiB
TypeScript
107 lines
3.1 KiB
TypeScript
import { useEffect, useRef, useCallback } from "react";
|
||
|
||
interface LazyEyesOptions {
|
||
/** Reference point the eyes "live" at (viewport coords). Eyes look away from this toward the mouse. */
|
||
anchorRef: React.RefObject<{ x: number; y: number } | null>;
|
||
/** Max pixel shift for the iris (default 1.5) */
|
||
maxShift?: number;
|
||
/** Lerp ease factor 0–1 for slow drift (default 0.04) */
|
||
ease?: number;
|
||
/** Saccade threshold — when target jumps more than this, snap fast (default 0.8) */
|
||
saccadeThreshold?: number;
|
||
/** Fast ease for saccade snap (default 0.35) */
|
||
saccadeEase?: number;
|
||
}
|
||
|
||
/**
|
||
* Returns a register function to attach iris elements for direct DOM updates.
|
||
* No React state is set per frame — transforms are applied directly.
|
||
*
|
||
* Movement model:
|
||
* - Small mouse moves → slow, lazy drift (ease)
|
||
* - Large jumps → quick saccade snap (saccadeEase), then settle
|
||
* - Tiny random micro-drift to avoid perfectly still eyes
|
||
*/
|
||
export function useLazyEyes({
|
||
anchorRef,
|
||
maxShift = 1.5,
|
||
ease = 0.04,
|
||
saccadeThreshold = 0.8,
|
||
saccadeEase = 0.35,
|
||
}: LazyEyesOptions) {
|
||
const targetRef = useRef({ x: 0, y: 0 });
|
||
const currentRef = useRef({ x: 0, y: 0 });
|
||
const velocityRef = useRef({ x: 0, y: 0 });
|
||
const irisesRef = useRef<Set<HTMLElement>>(new Set());
|
||
const offsetRef = useRef({ x: 0, y: 0 });
|
||
|
||
const registerIris = useCallback((el: HTMLElement | null) => {
|
||
if (el) {
|
||
irisesRef.current.add(el);
|
||
}
|
||
}, []);
|
||
|
||
const unregisterIris = useCallback((el: HTMLElement | null) => {
|
||
if (el) {
|
||
irisesRef.current.delete(el);
|
||
}
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
const onMouseMove = (e: MouseEvent) => {
|
||
const anchor = anchorRef.current;
|
||
if (!anchor) return;
|
||
const dx = e.clientX - anchor.x;
|
||
const dy = e.clientY - anchor.y;
|
||
const dist = Math.sqrt(dx * dx + dy * dy) || 1;
|
||
targetRef.current = {
|
||
x: (dx / dist) * maxShift,
|
||
y: (dy / dist) * maxShift,
|
||
};
|
||
};
|
||
|
||
window.addEventListener("mousemove", onMouseMove);
|
||
|
||
let raf = 0;
|
||
const tick = () => {
|
||
const ec = currentRef.current;
|
||
const et = targetRef.current;
|
||
const vel = velocityRef.current;
|
||
|
||
const dx = et.x - ec.x;
|
||
const dy = et.y - ec.y;
|
||
const dist = Math.sqrt(dx * dx + dy * dy);
|
||
|
||
const e_ = dist > saccadeThreshold ? saccadeEase : ease;
|
||
|
||
vel.x = vel.x * 0.6 + dx * e_ * 0.4;
|
||
vel.y = vel.y * 0.6 + dy * e_ * 0.4;
|
||
ec.x += vel.x;
|
||
ec.y += vel.y;
|
||
|
||
if (dist < 0.1) {
|
||
ec.x += (Math.random() - 0.5) * 0.02;
|
||
ec.y += (Math.random() - 0.5) * 0.02;
|
||
}
|
||
|
||
offsetRef.current.x = ec.x;
|
||
offsetRef.current.y = ec.y;
|
||
|
||
// Direct DOM updates — no React re-render
|
||
for (const iris of irisesRef.current) {
|
||
iris.style.transform = `translate(${ec.x}px, ${ec.y}px)`;
|
||
}
|
||
|
||
raf = requestAnimationFrame(tick);
|
||
};
|
||
raf = requestAnimationFrame(tick);
|
||
|
||
return () => {
|
||
window.removeEventListener("mousemove", onMouseMove);
|
||
cancelAnimationFrame(raf);
|
||
};
|
||
}, [anchorRef, maxShift, ease, saccadeThreshold, saccadeEase]);
|
||
|
||
return { offsetRef, registerIris, unregisterIris };
|
||
}
|