3D / Experimental · Intermediate
3D Card Tilt with Glare
Pointer position maps to rotateX/rotateY springs; a radial-gradient glare follows. Content lifts with translateZ in preserve-3d.
Live preview
Move a mouse over the card, or focus it and use arrow keys.
Copy-ready · type-checkedThe preview above renders this exact file. Reduced-motion fallback included.
/**
* Card Tilt
* A collectible editorial card with spring tilt and keyboard controls.
* Requires: motion, Tailwind with shadcn semantic tokens.
*/
import { useEffect, useId, useState } from "react";
import { motion, useReducedMotion, useSpring } from "motion/react";
export default function CardTilt({
reduced: reducedProp,
intensity = 1,
}: {
reduced?: boolean;
intensity?: number;
}) {
const systemReduced = useReducedMotion();
const reduced = reducedProp ?? systemReduced ?? false;
const amount = Math.min(1.5, Math.max(0.5, intensity));
const rx = useSpring(0, { stiffness: 160, damping: 22 });
const ry = useSpring(0, { stiffness: 160, damping: 22 });
const [selected, setSelected] = useState(false);
const instructionsId = useId();
const limit = 10 * amount;
useEffect(() => {
if (reduced) {
rx.jump(0);
ry.jump(0);
}
}, [reduced, rx, ry]);
function reset() {
rx.set(0);
ry.set(0);
}
return (
<section className="flex min-h-[80vh] flex-col items-center justify-center bg-background px-6 py-20 text-foreground">
<p className="mb-10 font-mono text-xs uppercase tracking-[0.2em] text-muted-foreground">
Objects of attention / 12
</p>
<div className="w-full max-w-[340px]" style={{ perspective: 1000 }}>
<motion.button
type="button"
aria-pressed={selected}
aria-describedby={instructionsId}
onClick={() => setSelected((value) => !value)}
onPointerLeave={reset}
onPointerCancel={reset}
onBlur={reset}
onPointerMove={(event) => {
if (
reduced ||
event.pointerType !== "mouse" ||
!window.matchMedia("(hover: hover) and (pointer: fine)").matches
)
return;
const rect = event.currentTarget.getBoundingClientRect();
ry.set(((event.clientX - rect.left) / rect.width - 0.5) * limit * 2);
rx.set((0.5 - (event.clientY - rect.top) / rect.height) * limit * 2);
}}
onKeyDown={(event) => {
if (reduced) return;
if (!["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown", "Escape"].includes(event.key))
return;
event.preventDefault();
if (event.key === "Escape") reset();
if (event.key === "ArrowLeft") ry.set(Math.max(-limit, ry.get() - 3 * amount));
if (event.key === "ArrowRight") ry.set(Math.min(limit, ry.get() + 3 * amount));
if (event.key === "ArrowUp") rx.set(Math.min(limit, rx.get() + 3 * amount));
if (event.key === "ArrowDown") rx.set(Math.max(-limit, rx.get() - 3 * amount));
}}
style={{
rotateX: reduced ? 0 : rx,
rotateY: reduced ? 0 : ry,
transformStyle: "preserve-3d",
}}
className="relative block aspect-[3/4] w-full overflow-hidden rounded-2xl border border-border bg-card text-left focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-8"
>
<img
src="https://picsum.photos/seed/ardent-monolith/900/1200"
alt="Photographic artwork for the Monolith collector edition."
className="absolute inset-0 h-full w-full object-cover"
/>
<div className="absolute inset-x-0 bottom-0 border-t border-border bg-background p-6">
<p className="font-mono text-xs uppercase tracking-[0.2em] text-muted-foreground">
Collector edition / 014
</p>
<h2 className="mt-3 font-serif text-5xl tracking-tight">Monolith</h2>
<p aria-live="polite" className="mt-3 text-sm text-muted-foreground">
{selected ? "Added to your collection" : "Select to collect"}
</p>
</div>
</motion.button>
</div>
<p
id={instructionsId}
className="mt-8 max-w-sm text-center text-sm leading-relaxed text-muted-foreground"
>
{reduced
? "Press Enter or tap the card to add it to your collection."
: "Move your pointer, or focus and use arrow keys. Escape resets the tilt."}
</p>
</section>
);
}
Next patternScroll Reveal Mask→