Tilt
The perspective sits on the wrapper, not on the tilting element. Putting it on the element itself makes the vanishing point travel with the card, so the projection is wrong everywhere except dead centre — the usual reason a tilt effect looks like a sticker rather than an object. The glare angle is derived from the same pointer vector as the rotation, so light and geometry describe one thing; a separately-animated shine reads as a highlight sliding across a flat surface.
Usage
/* Deepak Kumar E — https://craft.iam-deepak.space */ import { Tilt } from "@/components/tilt"; <Tilt />Customise
Lower is heavier and lags further behind the pointer.
0 removes the highlight entirely.
The component
components/craft/motion/Tilt.tsx
/* Deepak Kumar E — https://craft.iam-deepak.space */ import { useEffect, useRef } from "react"; /** * Tilts its children toward the pointer, with a glare that tracks the light. * * The perspective lives on the WRAPPER, not on the tilting element. Putting it * on the element itself makes the vanishing point follow the card as it moves, * so the projection is wrong everywhere except dead centre — the usual reason * a tilt effect looks subtly like a sticker instead of like an object. * * The glare is a gradient whose angle is derived from the same pointer vector * as the rotation, so light and geometry agree. A glare animated separately * reads as a shine sliding over a flat surface. * * Skipped entirely on coarse pointers: without hover there is nothing to tilt * toward, and a touch-drag belongs to the page's scroll. */ export interface TiltProps { children: React.ReactNode; /** Maximum rotation in degrees at the corners. */ max?: number; /** 0–1. Lower is heavier and lags further behind the pointer. */ ease?: number; /** Glare opacity. 0 removes it. */ glare?: number; /** Lift toward the viewer while hovered, in pixels. */ lift?: number; still?: boolean; className?: string;} export function Tilt({ children, max = 12, ease = 0.12, glare = 0.35, lift = 10, still = false, className = "",}: TiltProps) { const hostRef = useRef<HTMLDivElement>(null); const cardRef = useRef<HTMLDivElement>(null); const glareRef = useRef<HTMLDivElement>(null); useEffect(() => { const host = hostRef.current; const card = cardRef.current; const sheen = glareRef.current; if (!host || !card) return; const reduced = still || (typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches); const coarse = typeof matchMedia === "function" && matchMedia("(pointer: coarse)").matches; if (reduced || coarse) return; // Target and current are separate so the card eases rather than snapping // to each pointer sample. let targetX = 0; let targetY = 0; let targetZ = 0; let x = 0; let y = 0; let z = 0; let frame = 0; let running = false; const tick = () => { x += (targetX - x) * ease; y += (targetY - y) * ease; z += (targetZ - z) * ease; card.style.transform = `rotateX(${y.toFixed(2)}deg) rotateY(${x.toFixed(2)}deg) translateZ(${z.toFixed(2)}px)`; if (sheen) { // Same vector as the rotation, so the highlight and the geometry // describe one object rather than two effects. const angle = Math.atan2(y, x) * (180 / Math.PI) + 90; const strength = Math.min(1, Math.hypot(x, y) / max) * glare; sheen.style.background = `linear-gradient(${angle}deg, rgba(255,255,255,${strength.toFixed(3)}) 0%, rgba(255,255,255,0) 60%)`; } // Stop once settled instead of idling forever at sub-pixel deltas. if ( Math.abs(targetX - x) < 0.01 && Math.abs(targetY - y) < 0.01 && Math.abs(targetZ - z) < 0.01 ) { running = false; return; } frame = requestAnimationFrame(tick); }; const start = () => { if (running) return; running = true; frame = requestAnimationFrame(tick); }; const onMove = (event: PointerEvent) => { const box = host.getBoundingClientRect(); const px = (event.clientX - box.left) / box.width - 0.5; const py = (event.clientY - box.top) / box.height - 0.5; targetX = px * max * 2; // Inverted: pointer below centre should tip the TOP toward the viewer. targetY = -py * max * 2; targetZ = lift; start(); }; const onLeave = () => { targetX = 0; targetY = 0; targetZ = 0; start(); }; host.addEventListener("pointermove", onMove); host.addEventListener("pointerleave", onLeave); return () => { host.removeEventListener("pointermove", onMove); host.removeEventListener("pointerleave", onLeave); cancelAnimationFrame(frame); card.style.transform = ""; }; }, [max, ease, glare, lift, still]); return ( <div ref={hostRef} className={className} // Perspective on the wrapper: on the card itself the vanishing point // would travel with it and the projection would be wrong off-centre. style={{ perspective: 900 }} > <div ref={cardRef} className="relative will-change-transform"> {children} {glare > 0 ? ( <div ref={glareRef} aria-hidden className="pointer-events-none absolute inset-0 rounded-[inherit]" /> ) : null} </div> </div> );} One file. Paste it in, delete what you do not need, change what you do. The "use client" directive is stripped above — add it back if you are on the Next.js App Router.
What it costs
- Weight
+1.9KB gzipped · no new dependencies
- Runs on
Compositor
Animates transform and opacity only, so the compositor handles it off the main thread. Scroll and input stay responsive even while it runs.
- Reduced motion
No listeners and no transform. The card renders flat.
- Accessibility
- Adds no tab stops; whatever you wrap keeps its semantics.
- Skipped on coarse pointers, where there is no hover and a drag belongs to the page's scroll.
- The glare is aria-hidden and pointer-events-none, so it never intercepts a click.
- Watch out
- A rotated element creates a containing block: position:fixed children inside will anchor to the card rather than the viewport.
- The rAF loop stops once settled, but heavy content inside a transformed layer still costs memory to composite.