Files
sofa/hooks/use-tilt-effect.ts
T
jakeandClaude Opus 4.6 7a3052250d Optimize frontend: memoization, modern React hooks, reduced render overhead
- useTimeAgo: replace per-instance intervals with shared useSyncExternalStore ticker
- useTiltEffect: gate to fine-pointer devices, skip motion graphs on touch
- Carousel: bake WheelGesturesPlugin into primitive, remove from all consumers
- TitleSeasons: use memoized Set + precomputed progress map instead of Array.includes
- useSearch: stabilize results identity with useMemo
- CommandPalette: hoist shortcut grouping to module scope, stabilize effect deps
- StarRating: hoist static spring transition to module constant
- Settings toggles: use useOptimistic + useTransition for auto-rollback

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 16:15:08 -05:00

133 lines
3.7 KiB
TypeScript

"use client";
import {
type MotionStyle,
useMotionTemplate,
useMotionValue,
useSpring,
useTransform,
} from "motion/react";
import { useEffect, useRef, useState } from "react";
interface TiltConfig {
maxTilt?: number;
perspective?: number;
scale?: number;
glareMaxOpacity?: number;
parallaxShift?: number;
spring?: { stiffness: number; damping: number; mass: number };
}
const defaults = {
maxTilt: 8,
perspective: 800,
scale: 1.02,
glareMaxOpacity: 0.15,
parallaxShift: 5,
spring: { stiffness: 300, damping: 30, mass: 0.5 },
} satisfies Required<TiltConfig>;
export function useTiltEffect(config: TiltConfig = {}) {
const opts = { ...defaults, ...config };
const ref = useRef<HTMLDivElement>(null);
const rectRef = useRef<DOMRect | null>(null);
const [disabled, setDisabled] = useState(false);
useEffect(() => {
const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)");
const finePointer = window.matchMedia("(pointer: fine)");
const update = () =>
setDisabled(reducedMotion.matches || !finePointer.matches);
update();
reducedMotion.addEventListener("change", update);
finePointer.addEventListener("change", update);
return () => {
reducedMotion.removeEventListener("change", update);
finePointer.removeEventListener("change", update);
};
}, []);
// Normalized mouse position [0,1], center = 0.5
const mouseX = useMotionValue(0.5);
const mouseY = useMotionValue(0.5);
const isHovered = useMotionValue(0);
// Rotation (mouse left -> tilt right for natural feel)
const rawRotateY = useTransform(
mouseX,
[0, 1],
[opts.maxTilt, -opts.maxTilt],
);
const rawRotateX = useTransform(
mouseY,
[0, 1],
[-opts.maxTilt, opts.maxTilt],
);
const rawScale = useTransform(isHovered, [0, 1], [1, opts.scale]);
// Springs
const rotateX = useSpring(rawRotateX, opts.spring);
const rotateY = useSpring(rawRotateY, opts.spring);
const scale = useSpring(rawScale, opts.spring);
const hoverSpring = useSpring(isHovered, opts.spring);
// Parallax image offset (opposite direction)
const imageX = useSpring(
useTransform(mouseX, [0, 1], [opts.parallaxShift, -opts.parallaxShift]),
opts.spring,
);
const imageY = useSpring(
useTransform(mouseY, [0, 1], [opts.parallaxShift, -opts.parallaxShift]),
opts.spring,
);
// Glare
const glareX = useTransform(mouseX, [0, 1], [0, 100]);
const glareY = useTransform(mouseY, [0, 1], [0, 100]);
const glareOpacity = useTransform(
hoverSpring,
[0, 1],
[0, opts.glareMaxOpacity],
);
const glareBackground = useMotionTemplate`radial-gradient(circle at ${glareX}% ${glareY}%, rgba(255,255,255,0.25) 0%, transparent 60%)`;
function onMouseMove(e: React.MouseEvent) {
if (disabled || !rectRef.current) return;
const rect = rectRef.current;
const x = (e.clientX - rect.left) / rect.width;
const y = (e.clientY - rect.top) / rect.height;
mouseX.set(Math.max(0, Math.min(1, x)));
mouseY.set(Math.max(0, Math.min(1, y)));
}
function onMouseEnter() {
if (disabled) return;
rectRef.current = ref.current?.getBoundingClientRect() ?? null;
isHovered.set(1);
}
function onMouseLeave() {
if (disabled) return;
mouseX.set(0.5);
mouseY.set(0.5);
isHovered.set(0);
rectRef.current = null;
}
const containerStyle: MotionStyle = disabled
? {}
: { transformPerspective: opts.perspective, rotateX, rotateY, scale };
const imageStyle: MotionStyle = disabled ? {} : { x: imageX, y: imageY };
return {
ref,
containerStyle,
imageStyle,
glareBackground,
glareOpacity,
handlers: { onMouseMove, onMouseEnter, onMouseLeave },
disabled,
};
}