3D Flip Card
A card with real thickness that turns on a spring: a shaded rim, a glare that sweeps the face turning toward you, a floor shadow that narrows mid-turn, and a lean toward the pointer. Flips on hover, focus, click, tap or Enter. No dependencies.
DOM + CSS0 deps1 file · 15 KB TSXreduced-motion aware
Props
"use client";
/*
* FlipCard: a card with real thickness that turns over on a spring.
*
* Three nested layers do the 3D: a perspective layer, a tilt layer that leans
* toward the pointer, and a flipper that turns on the Y axis. Both faces sit on
* the flipper with backface-visibility hidden, and a stack of thin slabs between
* them gives the card a rim you can see when it is edge-on. The turn is a
* hand-written spring (a little weight, a hint of overshoot); the same angle
* drives a glare sweep across whichever face is turning toward you, and a
* floor shadow that narrows and fades while the card lifts mid-turn.
* Reduced motion drops the rotation and crossfades the faces instead.
*/
import { useEffect, useRef, useState, useSyncExternalStore, type CSSProperties, type KeyboardEvent, type PointerEvent, type ReactNode } from "react";
export interface FlipCardProps {
/** Text on the front face. */
frontLabel?: string;
/** Text on the back face. */
backLabel?: string;
/** "hover" flips while the pointer is over the card or it has keyboard focus (a tap toggles on touch); "click" toggles on click, tap, Enter or Space. */
trigger?: "hover" | "click";
/** Replaces the front label with your own content. */
front?: ReactNode;
/** Replaces the back label with your own content. */
back?: ReactNode;
/** How far the card leans toward the pointer, in degrees. 0 turns the lean off. */
tilt?: number;
/** Extra classes for the root. The root is the card box: size it here (the default is h-40 w-64). */
className?: string;
/** Called with true when the back face turns toward you, false when the front does. */
onFlipChange?: (flipped: boolean) => void;
}
/* ---- Motion constants ---------------------------------------------------- */
/** Flip spring: stiffness, damping. Damping ratio ~0.78, so one small overshoot (about 2 degrees). */
const FLIP_K = 170;
const FLIP_C = 20.4;
/** Lean spring, a touch softer than the flip. */
const LEAN_K = 120;
const LEAN_C = 17;
/** How fast the pointer glare follows the pointer and fades (per second). */
const GLARE_RATE = 14;
/** The card lifts toward you by up to this many px at the middle of the turn. */
const LIFT = 28;
const nowMs = () => performance.now();
function subscribeMotion(cb: () => void) {
const m = window.matchMedia("(prefers-reduced-motion: reduce)");
m.addEventListener("change", cb);
return () => m.removeEventListener("change", cb);
}
/** True when the visitor asked for reduced motion. False on the server. */
function useReducedMotion(): boolean {
return useSyncExternalStore(
subscribeMotion,
() => window.matchMedia("(prefers-reduced-motion: reduce)").matches,
() => false,
);
}
interface Spring {
x: number;
v: number;
}
function stepSpring(s: Spring, target: number, k: number, c: number, dt: number) {
s.v += (k * (target - s.x) - c * s.v) * dt;
s.x += s.v * dt;
}
/** Slab colours between the front rim and the back rim, so the edge shades from one face to the other. */
const SLABS = Array.from({ length: 10 }, (_, i) => {
const k = (i + 0.5) / 10 - 0.5;
const m = i / 9;
const mix = (a: number, b: number) => Math.round(a + (b - a) * m);
return { z: k * 0.9, color: `rgb(${mix(40, 134)} ${mix(41, 28)} ${mix(47, 30)})` };
});
const FACE_RADIUS = "5.2cqw";
const glareBackground = [
"radial-gradient(60% 80% at var(--gx) var(--gy), rgb(255 255 255 / calc(var(--pa) * 0.16)), transparent 70%)",
"linear-gradient(105deg, transparent calc(var(--sw) * 1% - 16%), rgb(255 255 255 / var(--sa)) calc(var(--sw) * 1%), transparent calc(var(--sw) * 1% + 16%))",
].join(",");
function Face({ side, hidden, reduced, decor, children }: { side: "front" | "back"; hidden: boolean; reduced: boolean; decor: boolean; children: ReactNode }) {
const front = side === "front";
const style: CSSProperties = {
borderRadius: FACE_RADIUS,
backfaceVisibility: "hidden",
WebkitBackfaceVisibility: "hidden",
transform: reduced ? undefined : front ? "translateZ(calc(var(--t) / 2))" : "rotateY(180deg) translateZ(calc(var(--t) / 2))",
background: front
? "radial-gradient(120% 90% at 16% 0%, rgb(255 255 255 / 0.1), transparent 55%), linear-gradient(155deg, #1d1e23 0%, #121317 55%, #0d0e11 100%)"
: "radial-gradient(110% 80% at 85% 0%, rgb(255 255 255 / 0.2), transparent 52%), linear-gradient(150deg, #ff5b49 0%, #e5332b 46%, #a1161d 100%)",
boxShadow: front
? "inset 0 0 0 1px rgb(255 255 255 / 0.1), inset 0 1px 0 rgb(255 255 255 / 0.16)"
: "inset 0 0 0 1px rgb(255 255 255 / 0.18), inset 0 1px 0 rgb(255 255 255 / 0.3)",
color: front ? "#f4f4f5" : "#ffffff",
opacity: reduced ? (hidden ? 0 : 1) : undefined,
transition: reduced ? "opacity 220ms ease" : undefined,
pointerEvents: reduced && hidden ? "none" : undefined,
};
return (
<div aria-hidden={hidden || undefined} className="absolute inset-0 overflow-hidden" style={style}>
{front ? null : (
<svg aria-hidden className="pointer-events-none absolute inset-0 h-full w-full" viewBox="0 0 160 100" preserveAspectRatio="xMidYMid slice" fill="none" stroke="#fff" strokeWidth="0.5" strokeOpacity="0.16">
<circle cx="142" cy="112" r="38" />
<circle cx="142" cy="112" r="62" />
<circle cx="142" cy="112" r="86" />
<circle cx="142" cy="112" r="110" />
</svg>
)}
<div className="relative flex h-full w-full items-center justify-center px-[8cqw] text-center">{children}</div>
{front && decor ? (
<>
<svg aria-hidden className="pointer-events-none absolute left-[6cqw] top-[6cqw] h-[5cqw] w-[5cqw] min-h-3 min-w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" opacity="0.38">
<circle cx="12" cy="12" r="9" />
<circle cx="12" cy="12" r="3.6" />
</svg>
<svg aria-hidden className="pointer-events-none absolute bottom-[6cqw] right-[6cqw] h-[5.4cqw] w-[5.4cqw] min-h-3.5 min-w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" opacity="0.4">
<path d="M20 11a8 8 0 0 0-14-4.5M4 4v3.5h3.5M4 13a8 8 0 0 0 14 4.5M20 20v-3.5h-3.5" />
</svg>
</>
) : null}
{reduced ? null : <div aria-hidden className="pointer-events-none absolute inset-0" style={{ background: glareBackground }} />}
</div>
);
}
export function FlipCard({
frontLabel = "Hover to flip",
backLabel = "Here's the back",
trigger = "hover",
front,
back,
tilt = 6,
className,
onFlipChange,
}: FlipCardProps) {
const reduced = useReducedMotion();
const [clicked, setClicked] = useState(false);
const [hovered, setHovered] = useState(false);
const [focused, setFocused] = useState(false);
const [tapped, setTapped] = useState(false);
const flipped = trigger === "click" ? clicked : hovered || focused || tapped;
const rootRef = useRef<HTMLDivElement>(null);
const tiltRef = useRef<HTMLDivElement>(null);
const flipRef = useRef<HTMLDivElement>(null);
const shadowRef = useRef<HTMLDivElement>(null);
const lastPointer = useRef("mouse");
const wake = useRef<() => void>(() => {});
const target = useRef({ angle: 0, rx: 0, ry: 0, gx: 50, gy: 50, pa: 0 });
const tiltMax = useRef(tilt);
const onFlip = useRef(onFlipChange);
const prevFlipped = useRef(false);
useEffect(() => {
tiltMax.current = tilt;
onFlip.current = onFlipChange;
});
/* The spring loop: runs only while something is moving. */
useEffect(() => {
const tl = tiltRef.current;
const fl = flipRef.current;
const sh = shadowRef.current;
const root = rootRef.current;
if (!tl || !fl || !sh || !root) return;
if (reduced) {
tl.style.transform = "";
fl.style.transform = "";
sh.style.transform = "";
sh.style.opacity = "";
wake.current = () => {};
return;
}
const tg = target.current;
const angle: Spring = { x: tg.angle, v: 0 };
const rx: Spring = { x: 0, v: 0 };
const ry: Spring = { x: 0, v: 0 };
let gx = tg.gx;
let gy = tg.gy;
let pa = 0;
let raf = 0;
let last = 0;
const apply = () => {
const rad = (angle.x * Math.PI) / 180;
const sn = Math.abs(Math.sin(rad));
const cs = Math.abs(Math.cos(rad));
fl.style.transform = `translateZ(${(sn * LIFT).toFixed(2)}px) rotateY(${angle.x.toFixed(3)}deg)`;
tl.style.transform = `rotateX(${rx.x.toFixed(3)}deg) rotateY(${ry.x.toFixed(3)}deg)`;
sh.style.transform = `translateX(-50%) scale(${(0.4 + 0.6 * cs).toFixed(3)}, ${(1 - 0.3 * sn).toFixed(3)})`;
sh.style.opacity = (1 - 0.6 * sn).toFixed(3);
const p = angle.x / 180;
const q = p <= 0.5 ? p : p - 0.5;
root.style.setProperty("--sw", (-30 + 260 * q).toFixed(2));
root.style.setProperty("--sa", (0.5 * Math.max(0, Math.sin(2 * Math.PI * q))).toFixed(3));
root.style.setProperty("--gx", gx.toFixed(2) + "%");
root.style.setProperty("--gy", gy.toFixed(2) + "%");
root.style.setProperty("--pa", pa.toFixed(3));
};
const step = (t: number) => {
raf = 0;
const dt = Math.min(0.032, Math.max(0.001, (t - last) / 1000));
last = t;
const h = dt / 2;
for (let i = 0; i < 2; i++) {
stepSpring(angle, tg.angle, FLIP_K, FLIP_C, h);
stepSpring(rx, tg.rx, LEAN_K, LEAN_C, h);
stepSpring(ry, tg.ry, LEAN_K, LEAN_C, h);
}
const f = 1 - Math.exp(-dt * GLARE_RATE);
gx += (tg.gx - gx) * f;
gy += (tg.gy - gy) * f;
pa += (tg.pa - pa) * f;
const settled =
Math.abs(angle.x - tg.angle) < 0.04 && Math.abs(angle.v) < 0.6 &&
Math.abs(rx.x - tg.rx) < 0.02 && Math.abs(rx.v) < 0.1 &&
Math.abs(ry.x - tg.ry) < 0.02 && Math.abs(ry.v) < 0.1 &&
Math.abs(pa - tg.pa) < 0.004 && Math.abs(gx - tg.gx) < 0.1 && Math.abs(gy - tg.gy) < 0.1;
if (settled) {
angle.x = tg.angle; angle.v = 0;
rx.x = tg.rx; rx.v = 0;
ry.x = tg.ry; ry.v = 0;
gx = tg.gx; gy = tg.gy; pa = tg.pa;
apply();
return;
}
apply();
raf = requestAnimationFrame(step);
};
wake.current = () => {
if (raf) return;
last = nowMs();
raf = requestAnimationFrame(step);
};
apply();
wake.current();
return () => {
if (raf) cancelAnimationFrame(raf);
raf = 0;
wake.current = () => {};
};
}, [reduced]);
/* A new face is wanted: aim the spring and tell the host. */
useEffect(() => {
target.current.angle = flipped ? 180 : 0;
wake.current();
if (prevFlipped.current !== flipped) {
prevFlipped.current = flipped;
onFlip.current?.(flipped);
}
}, [flipped]);
const aim = (e: PointerEvent<HTMLDivElement>) => {
const el = rootRef.current;
if (!el || reduced || e.pointerType === "touch") return;
const r = el.getBoundingClientRect();
if (r.width === 0 || r.height === 0) return;
// Layout pixels: the library scales previews with a transform.
const x = (e.clientX - r.left) * (el.offsetWidth / r.width);
const y = (e.clientY - r.top) * (el.offsetHeight / r.height);
const nx = Math.max(-1, Math.min(1, (x / el.offsetWidth) * 2 - 1));
const ny = Math.max(-1, Math.min(1, (y / el.offsetHeight) * 2 - 1));
const t = target.current;
t.ry = nx * tiltMax.current;
t.rx = -ny * tiltMax.current;
t.gx = (x / el.offsetWidth) * 100;
t.gy = (y / el.offsetHeight) * 100;
t.pa = 1;
wake.current();
};
const onKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
if (trigger === "click" && (e.key === "Enter" || e.key === " ")) {
e.preventDefault();
setClicked((v) => !v);
}
};
const rootStyle = {
"--sw": -30,
"--sa": 0,
"--gx": "50%",
"--gy": "50%",
"--pa": 0,
} as CSSProperties;
return (
<div
ref={rootRef}
tabIndex={0}
role={trigger === "click" ? "button" : "group"}
aria-roledescription={trigger === "click" ? undefined : "flip card"}
aria-pressed={trigger === "click" ? flipped : undefined}
className={`group relative block h-40 w-64 max-w-full cursor-pointer select-none [container-type:inline-size] [touch-action:manipulation] focus-visible:!outline-none${className ? " " + className : ""}`}
style={rootStyle}
onPointerDown={(e) => {
lastPointer.current = e.pointerType;
}}
onPointerEnter={(e) => {
if (e.pointerType !== "touch") setHovered(true);
}}
onPointerMove={aim}
onPointerLeave={(e) => {
if (e.pointerType === "touch") return;
setHovered(false);
const t = target.current;
t.rx = 0;
t.ry = 0;
t.pa = 0;
wake.current();
}}
onClick={() => {
if (trigger === "click") setClicked((v) => !v);
else if (lastPointer.current === "touch") setTapped((v) => !v);
}}
onKeyDown={onKeyDown}
onFocus={(e) => {
if (e.currentTarget.matches(":focus-visible")) setFocused(true);
}}
onBlur={() => {
setFocused(false);
setTapped(false);
}}
>
{/* Floor shadow: narrows and fades while the card is lifted mid-turn. */}
<div
ref={shadowRef}
aria-hidden
className="pointer-events-none absolute left-1/2 top-[94%] h-[15%] w-[86%] [transform-origin:50%_0%] [transform:translateX(-50%)]"
style={{ background: "radial-gradient(closest-side, rgb(0 0 0 / 0.4), rgb(0 0 0 / 0.14) 55%, rgb(0 0 0 / 0))" }}
/>
<div className="absolute inset-0" style={{ perspective: "max(900px, 230cqw)", ["--t" as string]: "clamp(6px, 2.4cqw, 16px)" } as CSSProperties}>
<div ref={tiltRef} className="absolute inset-0 [transform-style:preserve-3d]">
<div ref={flipRef} className="absolute inset-0 [transform-style:preserve-3d]">
{reduced
? null
: SLABS.map((s) => (
<div
key={s.z}
aria-hidden
className="absolute inset-px"
style={{ borderRadius: FACE_RADIUS, background: s.color, transform: `translateZ(calc(var(--t) * ${s.z.toFixed(3)}))` }}
/>
))}
<Face side="front" hidden={flipped} reduced={reduced} decor={front === undefined}>
{front ?? <span className="text-balance text-[clamp(14px,7cqw,44px)] font-semibold leading-tight tracking-[-0.02em]">{frontLabel}</span>}
</Face>
<Face side="back" hidden={!flipped} reduced={reduced} decor={back === undefined}>
{back ?? <span className="text-balance text-[clamp(14px,7cqw,44px)] font-semibold leading-tight tracking-[-0.02em]">{backLabel}</span>}
</Face>
</div>
</div>
</div>
{/* Focus ring: two-tone so it reads on light and dark pages. */}
<div
aria-hidden
className="pointer-events-none absolute opacity-0 group-focus-visible:opacity-100"
style={{ inset: -6, borderRadius: "calc(5.2cqw + 6px)", boxShadow: "0 0 0 2px #fff, 0 0 0 4px #ef4444" }}
/>
</div>
);
}
Free. Pro unlocks everything, from $27/month.
Details
About this component
A flip card shows two states of one object without a cross-fade that makes them feel unrelated. This one is a thing with weight. Three nested layers do the 3D: a perspective layer, a tilt layer that leans the card a few degrees toward the pointer, and a flipper that turns on the Y axis. Both faces use backface-visibility: hidden, and ten thin slabs between them give the card a shaded rim you see when it is edge-on. The turn is a spring (stiffness 170, damping 20.4) with one small overshoot, run in a single requestAnimationFrame loop that sleeps when the card is at rest. The loop writes the angle and nothing else is animated by hand: the card lifts toward you by up to 28px at the middle of the turn, a soft glare band sweeps across whichever face is turning toward you, and a floor shadow narrows and fades while the card is lifted. A faint highlight follows the pointer when it hovers. In hover mode (the default) the card flips while the pointer is over it or it has keyboard focus, and a tap toggles it on touch; in click mode a click, tap, Enter or Space toggles it. Pass front and back to put your own content on the faces, size the card through className (the default is h-40 w-64; type scales with the card width), and use onFlipChange to follow the state. Skip it for faces that must stay legible mid-turn: at 90 degrees the card is edge-on, so don't put a running timer or a live form on either side.
Curator’s note
Rebuilt in October without Framer Motion: the turn is a small hand-written spring, and the same angle drives the glare, the lift and the shadow, which is what makes it feel like an object and not a rotating rectangle.
