Tilt Card
A 3D card that tilts toward the pointer on hand-written springs, with a picture, badge, title and chips floating at their own depths, a lit edge and glare that follow the pointer, and a shadow that slides the other way.
DOM + CSS0 deps1 file · 16 KB TSXreduced-motion aware
Props
"use client";
import { useEffect, useId, useRef } from "react";
import type { CSSProperties, ReactNode } from "react";
/**
* TiltCard: a card that tilts in 3D toward the pointer on hand-written springs.
*
* The card sits in a perspective box. Its face is flat; everything on it floats
* at its own translateZ (the picture window, the badge, the title, the chips),
* so the tilt reads as real parallax. Inside the picture window the artwork
* slides in 2D layers at different speeds, and a soft glare plus a lit edge
* follow the pointer. The shadow underneath slides the opposite way.
*
* Touch has no hover: tap or drag and the card tilts toward your finger, then
* settles. Left alone it sways slowly (turn off with idle={false}). Reduced
* motion keeps the card flat and only moves the light. No dependencies.
*/
const HEX = /^#[0-9a-fA-F]{6}$/;
const DEFAULT_ACCENT = "#ffb86b";
const DEFAULT_CHIPS = ["Spring", "Parallax"];
const nowMs = () => performance.now();
function rgba(hex: string, a: number): string {
const n = parseInt(hex.slice(1), 16);
return `rgba(${(n >> 16) & 255},${(n >> 8) & 255},${n & 255},${a})`;
}
/** One spring channel: position x chases target t with stiffness k and damping c. */
interface Chan {
x: number;
v: number;
t: number;
k: number;
c: number;
}
const chan = (k: number, c: number, x = 0): Chan => ({ x, v: 0, t: x, k, c });
/** Float a layer `z` px above the card face and slide it `k` px against the pointer. */
const layer = (z: number, k: number): CSSProperties => ({
transform: `translate3d(calc(var(--px, 0) * ${-k}px), calc(var(--py, 0) * ${-k}px), ${z}px)`,
});
const FLAT3D: CSSProperties = { transformStyle: "preserve-3d" };
export type TiltCardProps = {
title?: string;
body?: string;
/** Largest tilt in degrees, reached at the card's edge. */
maxTilt?: number;
/** Soft highlight and lit edge that follow the pointer. */
glare?: boolean;
/** Small label on the picture, e.g. "Featured". */
eyebrow?: string;
/** Chips under the text. */
chips?: string[];
/** #rrggbb: the sun, the badge dot, the arrow. */
accent?: string;
/** Your own picture for the window (any element; it fills the window). */
art?: ReactNode;
/** Sway slowly when nobody is pointing at it. */
idle?: boolean;
className?: string;
style?: CSSProperties;
};
export function TiltCard({
title = "Editorial Card",
body = "Tilts toward your cursor with a soft spring.",
maxTilt = 12,
glare = true,
eyebrow = "Featured",
chips = DEFAULT_CHIPS,
accent = DEFAULT_ACCENT,
art,
idle = true,
className,
style,
}: TiltCardProps) {
const acc = HEX.test(accent) ? accent : DEFAULT_ACCENT;
const rootRef = useRef<HTMLElement>(null);
const tiltRef = useRef<HTMLDivElement>(null);
const shadowRef = useRef<HTMLDivElement>(null);
const live = useRef({ maxTilt, idle });
const wake = useRef<() => void>(() => {});
useEffect(() => {
live.current = { maxTilt: Math.min(45, Math.max(0, maxTilt)), idle };
wake.current();
}, [maxTilt, idle]);
useEffect(() => {
const root = rootRef.current;
const tilt = tiltRef.current;
const shadow = shadowRef.current;
if (!root || !tilt || !shadow) return;
const mql = window.matchMedia("(prefers-reduced-motion: reduce)");
let reduced = mql.matches;
const px = chan(200, 20);
const py = chan(200, 20);
const lift = chan(260, 24);
const glow = chan(170, 22);
const gx = chan(260, 26, 50);
const gy = chan(260, 26, 30);
const all = [px, py, lift, glow, gx, gy];
let ptrX = 0;
let ptrY = 0;
let active = false;
let pressed = false;
let leftAt = nowMs() - 900;
let seen = true;
let raf = 0;
let last = 0;
let timer = 0;
const aim = (e: PointerEvent) => {
const r = root.getBoundingClientRect();
ptrX = Math.max(-1, Math.min(1, ((e.clientX - r.left) / (r.width || 1)) * 2 - 1));
ptrY = Math.max(-1, Math.min(1, ((e.clientY - r.top) / (r.height || 1)) * 2 - 1));
};
const step = (ts: number) => {
raf = 0;
const dt = last ? Math.min(0.05, (ts - last) / 1000) : 1 / 60;
last = ts;
const { maxTilt: mt, idle: idleOn } = live.current;
const swaying = !active && idleOn && !reduced;
const g = swaying ? Math.min(1, Math.max(0, (ts - leftAt - 1100) / 1800)) : 0;
const ease = g * g * (3 - 2 * g);
const ix = Math.sin(ts * 0.00052) * 0.36 * ease;
const iy = Math.cos(ts * 0.00039) * 0.3 * ease;
if (active) {
px.t = reduced ? 0 : ptrX;
py.t = reduced ? 0 : ptrY;
gx.t = 50 + ptrX * 50;
gy.t = 50 + ptrY * 50;
glow.t = 1;
lift.t = reduced ? 0 : pressed ? -0.6 : 1;
} else {
px.t = ix;
py.t = iy;
if (swaying) {
gx.t = 50 + ix * 140;
gy.t = 42 + iy * 140;
}
glow.t = 0.55 * ease;
lift.t = 0;
}
const n = Math.max(1, Math.ceil(dt / 0.008));
const h = dt / n;
for (let i = 0; i < n; i++) {
for (const s of all) {
s.v += (s.k * (s.t - s.x) - s.c * s.v) * h;
s.x += s.v * h;
}
}
const rx = -py.x * mt;
const ry = px.x * mt;
tilt.style.transform = `rotateX(${rx.toFixed(3)}deg) rotateY(${ry.toFixed(3)}deg) scale(${(1 + lift.x * 0.025).toFixed(4)})`;
root.style.setProperty("--px", px.x.toFixed(3));
root.style.setProperty("--py", py.x.toFixed(3));
root.style.setProperty("--gx", gx.x.toFixed(2) + "%");
root.style.setProperty("--gy", gy.x.toFixed(2) + "%");
root.style.setProperty("--gl", Math.max(0, Math.min(1, glow.x)).toFixed(3));
const raise = Math.max(0, lift.x);
shadow.style.transform = `translate3d(${(-px.x * 16).toFixed(2)}px, ${(18 + raise * 10 - py.x * 8).toFixed(2)}px, 0) scale(${(1 + raise * 0.03).toFixed(3)})`;
shadow.style.opacity = (0.5 + Math.max(0, glow.x) * 0.2).toFixed(3);
const moving = all.some((s) => Math.abs(s.t - s.x) > 0.0008 || Math.abs(s.v) > 0.002);
if (seen && !document.hidden && (moving || swaying)) raf = requestAnimationFrame(step);
else last = 0;
};
const go = () => {
if (!raf && seen && !document.hidden) raf = requestAnimationFrame(step);
};
wake.current = go;
const release = () => {
window.clearTimeout(timer);
active = false;
pressed = false;
leftAt = nowMs();
go();
};
const onEnter = (e: PointerEvent) => {
if (e.pointerType === "touch") return;
active = true;
aim(e);
go();
};
const onMove = (e: PointerEvent) => {
active = true;
aim(e);
go();
};
const onDown = (e: PointerEvent) => {
active = true;
aim(e);
if (e.pointerType === "mouse") pressed = true;
else {
window.clearTimeout(timer);
timer = window.setTimeout(release, 700);
}
go();
};
const onUp = (e: PointerEvent) => {
pressed = false;
if (e.pointerType !== "mouse") {
window.clearTimeout(timer);
timer = window.setTimeout(release, 320);
}
go();
};
const onLeave = (e: PointerEvent) => {
if (e.pointerType !== "touch") release();
};
const onMotion = () => {
reduced = mql.matches;
go();
};
const onVisible = () => go();
root.addEventListener("pointerenter", onEnter);
root.addEventListener("pointermove", onMove);
root.addEventListener("pointerdown", onDown);
root.addEventListener("pointerup", onUp);
root.addEventListener("pointerleave", onLeave);
root.addEventListener("pointercancel", release);
mql.addEventListener("change", onMotion);
document.addEventListener("visibilitychange", onVisible);
const io = new IntersectionObserver(
(list) => {
seen = list.some((l) => l.isIntersecting);
if (seen) go();
},
{ rootMargin: "100px" },
);
io.observe(root);
go();
return () => {
cancelAnimationFrame(raf);
window.clearTimeout(timer);
io.disconnect();
root.removeEventListener("pointerenter", onEnter);
root.removeEventListener("pointermove", onMove);
root.removeEventListener("pointerdown", onDown);
root.removeEventListener("pointerup", onUp);
root.removeEventListener("pointerleave", onLeave);
root.removeEventListener("pointercancel", release);
mql.removeEventListener("change", onMotion);
document.removeEventListener("visibilitychange", onVisible);
wake.current = () => {};
};
}, []);
const hairline = "inset 0 0 0 1px rgba(255,255,255,0.09)";
return (
<article
ref={rootRef}
aria-label={title}
className={"relative block w-64 max-w-full" + (className ? " " + className : "")}
style={{ perspective: 900, touchAction: "pan-y", ...style }}
>
<div
ref={shadowRef}
aria-hidden
className="pointer-events-none absolute inset-x-[7%] bottom-0 top-[14%] rounded-[28px]"
style={{ background: "rgba(0,0,0,0.85)", filter: "blur(26px)", opacity: 0.5, transform: "translate3d(0,18px,0)" }}
/>
<div ref={tiltRef} className="relative" style={{ ...FLAT3D, willChange: "transform" }}>
{/* The face: flat, clipped, lit. */}
<div aria-hidden className="absolute inset-0 overflow-hidden rounded-3xl" style={{ background: "linear-gradient(180deg,#16181d 0%,#0c0d10 100%)", boxShadow: `${hairline}, 0 1px 0 rgba(255,255,255,0.05) inset` }}>
{glare && (
<>
<div
className="absolute inset-0"
style={{ background: "radial-gradient(280px circle at var(--gx, 50%) var(--gy, 30%), rgba(255,255,255,0.1), transparent 62%)", opacity: "var(--gl, 0)" }}
/>
<div
className="absolute inset-0 rounded-3xl"
style={{
padding: 1,
background: `radial-gradient(200px circle at var(--gx, 50%) var(--gy, 30%), rgba(255,255,255,0.75), ${rgba(acc, 0.28)} 45%, transparent 72%)`,
WebkitMask: "linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0)",
WebkitMaskComposite: "xor",
mask: "linear-gradient(#000 0 0) content-box exclude, linear-gradient(#000 0 0)",
opacity: "var(--gl, 0)",
}}
/>
</>
)}
</div>
<div className="relative p-2.5" style={FLAT3D}>
<div className="relative" style={FLAT3D}>
<div className="relative aspect-[16/11] overflow-hidden rounded-[18px]" style={{ ...layer(16, 0), background: "#0a0d24", boxShadow: "0 0 0 1px rgba(255,255,255,0.08)" }}>
{art ?? <Dusk accent={acc} />}
{glare && (
<div
aria-hidden
className="pointer-events-none absolute inset-0"
style={{
background: "radial-gradient(150px circle at var(--gx, 50%) var(--gy, 30%), rgba(255,255,255,0.3), transparent 70%)",
mixBlendMode: "soft-light",
opacity: "var(--gl, 0)",
}}
/>
)}
</div>
{eyebrow ? (
<span
className="absolute left-3 top-3 inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-[10px] font-medium uppercase leading-none tracking-[0.16em] text-white/90"
style={{ ...layer(48, 3), background: "rgba(8,9,16,0.62)", boxShadow: "0 0 0 1px rgba(255,255,255,0.12), 0 6px 14px rgba(0,0,0,0.35)" }}
>
<span aria-hidden className="h-1.5 w-1.5 rounded-full" style={{ background: acc, boxShadow: `0 0 8px ${acc}` }} />
{eyebrow}
</span>
) : null}
</div>
<div className="px-2 pb-1.5 pt-4" style={FLAT3D}>
<h3 className="text-[19px] font-semibold leading-tight tracking-[-0.02em] text-white/95" style={layer(34, 2)}>
{title}
</h3>
<p className="mt-1.5 text-[13px] leading-[1.5] text-white/60" style={layer(22, 1.2)}>
{body}
</p>
<div className="mt-4 flex items-end justify-between gap-3" style={{ ...FLAT3D, ...layer(54, 3) }}>
<ul className="m-0 flex min-w-0 list-none flex-wrap gap-1.5 p-0">
{chips.map((c, i) => (
<li key={c + i} className="rounded-full px-2.5 py-1 text-[11px] leading-none text-white/75" style={{ background: "rgba(255,255,255,0.06)", boxShadow: "inset 0 0 0 1px rgba(255,255,255,0.1)" }}>
{c}
</li>
))}
</ul>
<span aria-hidden className="grid h-8 w-8 shrink-0 place-items-center rounded-full" style={{ background: acc, boxShadow: `0 8px 18px ${rgba(acc, 0.35)}` }}>
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="#14110c" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<path d="M3 7h8M7.5 3.5 11 7l-3.5 3.5" />
</svg>
</span>
</div>
</div>
</div>
</div>
</article>
);
}
/** The default picture: a dusk coastline in four layers that slide at different speeds. */
function Dusk({ accent }: { accent: string }) {
const id = useId().replace(/[^a-zA-Z0-9]/g, "");
const slide = (k: number): CSSProperties => ({
transform: `translate3d(calc(var(--px, 0) * ${-k}px), calc(var(--py, 0) * ${-k * 0.6}px), 0)`,
});
const pine = (x: number, y: number, s: number) =>
`M${x} ${y - 26 * s}L${x - 7 * s} ${y - 8 * s}H${x - 3 * s}L${x - 9 * s} ${y + 6 * s}H${x + 9 * s}L${x + 3 * s} ${y - 8 * s}H${x + 7 * s}Z`;
const full = "absolute -inset-[9%] h-[118%] w-[118%]";
return (
<div aria-hidden className="absolute inset-0">
<div className={full} style={{ ...slide(2), background: `linear-gradient(180deg,#0a0d24 0%,#171b45 48%,#2a2a5e 70%,${rgba(accent, 0.55)} 100%)` }}>
<svg viewBox="0 0 320 220" className="h-full w-full" preserveAspectRatio="xMidYMax slice">
{[
[34, 24, 0.9],
[70, 52, 0.6],
[112, 20, 0.8],
[150, 40, 0.5],
[262, 28, 0.9],
[292, 60, 0.6],
[186, 14, 0.5],
].map(([x, y, o], i) => (
<circle key={i} cx={x} cy={y} r={0.9} fill="#fff" opacity={o} />
))}
</svg>
</div>
<svg viewBox="0 0 320 220" className={full} style={slide(4)} preserveAspectRatio="xMidYMax slice">
<defs>
<radialGradient id={id + "g"}>
<stop offset="0" stopColor={accent} stopOpacity="0.55" />
<stop offset="1" stopColor={accent} stopOpacity="0" />
</radialGradient>
</defs>
<circle cx="214" cy="128" r="92" fill={`url(#${id}g)`} />
<circle cx="214" cy="128" r="25" fill={accent} />
</svg>
<svg viewBox="0 0 320 220" className={full} style={slide(7)} preserveAspectRatio="xMidYMax slice">
<defs>
<linearGradient id={id + "f"} x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stopColor="#35366a" />
<stop offset="0.5" stopColor="#262957" />
</linearGradient>
</defs>
<path d="M-10 150L30 120L58 138L96 104L128 132L160 112L196 140L238 108L274 134L330 114V230H-10Z" fill={`url(#${id}f)`} />
</svg>
<svg viewBox="0 0 320 220" className={full} style={slide(11)} preserveAspectRatio="xMidYMax slice">
<path d="M-10 172L24 150L62 166L104 138L146 162L190 144L236 168L282 146L330 162V230H-10Z" fill="#1a1d42" />
</svg>
<svg viewBox="0 0 320 220" className={full} style={slide(16)} preserveAspectRatio="xMidYMax slice">
<path d="M-10 198L40 178L84 192L132 174L180 194L230 180L284 196L330 184V230H-10Z" fill="#0b0d1f" />
<path d={pine(26, 184, 1) + pine(46, 188, 0.8) + pine(272, 190, 1.1) + pine(296, 186, 0.85) + pine(252, 194, 0.7)} fill="#0b0d1f" />
<path d="M120 206H196M138 212H178" stroke={accent} strokeOpacity="0.4" strokeWidth="1.2" strokeLinecap="round" />
</svg>
</div>
);
}
Free. Pro unlocks everything, from $27/month.
Details
About this component
A card that tilts in 3D toward the pointer, built from layers. The card sits in a perspective box; its face is flat, and the picture window, the badge, the title, the body and the chips each float at their own translateZ, so tilting it produces real parallax instead of a flat skew. Inside the picture the artwork slides in four 2D layers at different speeds. A soft glare, a lit hairline edge and a spot on the picture follow the pointer, and the shadow underneath slides the opposite way and softens as the card lifts. The motion is a small set of hand-written springs (200 stiffness, 20 damping for the tilt, a stiffer pair for the light), integrated in 8ms steps inside one effect and written straight to CSS variables, so pointer moves cause no React renders and the loop sleeps whenever the card is still, off screen, or the tab is hidden. Left alone the card sways slowly (idle={false} turns that off). Touch has no hover, so a tap or a drag tilts it toward your finger and it settles a moment after you let go. The one rule when adapting it: nothing between the tilt element and a floating layer may clip (overflow hidden, clip-path, filter), or the depth flattens; clip the face and the picture window instead. Props: title, body, maxTilt (degrees), glare, plus optional eyebrow, chips, accent (#rrggbb), art (your own picture), idle, className and style. The root is 16rem wide by default; pass className or style to size it. React only, no dependencies.
Curator’s note
Rebuilt in October without Framer Motion: the springs are hand-written, and the card now has real depth layers instead of a flat face.

