3D Card Carousel
A coverflow carousel: cards on an arc, the front one facing you while its neighbours turn in, dim and blur with depth. Drag or swipe with momentum that snaps to a card, arrows, dots, keyboard, tap a side card. No dependencies.
DOM + CSS0 deps1 file · 27 KB TSXreduced-motion aware
Props
"use client";
/*
* CardCarousel3D: a coverflow carousel. Cards sit on an arc in front of you: the
* current card faces forward, its neighbours turn toward it, tuck back in depth,
* dim and blur a little more the further they are.
*
* One continuous number (pos) says which card is in front. Every card's pose is a
* pure function of its signed distance to pos, written straight to the DOM from a
* single requestAnimationFrame loop that sleeps when everything is at rest. Drag
* or swipe moves pos 1:1, release throws it (velocity projected, then snapped to a
* card) and a hand-written spring settles it. Arrows, dots, keyboard, a trackpad
* swipe and a tap on a side card all move the same spring target.
* No animation library; sizes come from one CSS variable (--cc3d-card-w).
* Reduced motion drops the spring and the pointer parallax: cards jump into place.
*/
import { useEffect, useId, useRef, useState, type CSSProperties, type KeyboardEvent, type PointerEvent, type ReactNode } from "react";
export interface CardCarousel3DProps {
/** Card titles, one per card. */
cards?: string[];
/** Replaces the built-in card face. Receives the title, its index and whether it is the current card. */
renderCard?: (card: string, index: number, active: boolean) => ReactNode;
/** Which card starts in front. */
defaultIndex?: number;
/** Called with the new index whenever the card in front changes. */
onChange?: (index: number) => void;
/** Wrap around at the ends (needs 3+ cards). When false the ends resist and the arrows disable. */
loop?: boolean;
/** Accessible name of the carousel. */
label?: string;
/** Extra classes for the root. Set the card width with a class like [--cc3d-card-w:240px]. */
className?: string;
}
const DEFAULT_CARDS = ["Design", "Build", "Ship", "Iterate"];
/* ---- Motion and geometry constants -------------------------------------- */
/** Spring toward the target card: stiffness, damping (ratio ~0.94, no visible overshoot). */
const SPRING_K = 150;
const SPRING_C = 23;
/** Distance from the front card to its first neighbour, in card widths. Drag maps 1 card to this much pointer travel. */
const STEP = 0.74;
/** Extra spacing per card beyond the first neighbour. */
const FAR_STEP = 0.36;
/** Angle the neighbours turn toward the front card, in degrees. */
const TURN = 46;
/** How far a release throw is projected: seconds of velocity added to the position. */
const THROW = 0.17;
/** Pointer travel (px) before a press becomes a drag. */
const DRAG_SLOP = 5;
const clamp = (v: number, a: number, b: number) => Math.min(b, Math.max(a, v));
const f4 = (v: number) => (Math.abs(v) < 0.00005 ? "0" : v.toFixed(4));
const mod = (a: number, n: number) => ((a % n) + n) % n;
/* ---- Pose: everything about a card is a function of its signed distance d to the front card ---- */
interface Pose {
transform: string;
opacity: string;
zIndex: string;
visibility: "visible" | "hidden";
filter: string;
dim: string;
sheen: string;
focus: string;
}
function pose(d: number, n: number, loop: boolean): Pose {
const a = Math.abs(d);
const s = d < 0 ? -1 : 1;
const near = Math.min(a, 1);
const far = Math.min(Math.max(a - 1, 0), 2.5);
const x = s * (near * STEP + far * FAR_STEP);
const z = -(near * 0.42 + far * 0.2);
const rot = -s * near * TURN;
const scale = 1 - near * 0.04 - far * 0.05;
let op = a < 1.4 ? 1 : a < 2.2 ? 1 - (a - 1.4) * 0.55 : a < 3.2 ? 0.56 * (3.2 - a) : 0;
if (loop) op *= clamp((n / 2 - a) / 0.7, 0, 1);
const blur = Math.min(a, 2.2) * 1.15;
return {
transform: `translate3d(calc(var(--cc-w) * ${f4(x)}), 0, calc(var(--cc-w) * ${f4(z)})) rotateY(${f4(rot)}deg) scale(${f4(scale)})`,
opacity: f4(op),
zIndex: String(1000 - Math.round(a * 100)),
visibility: op > 0.01 ? "visible" : "hidden",
filter: blur < 0.05 ? "none" : `blur(${blur.toFixed(2)}px)`,
dim: f4(near * 0.3 + far * 0.1),
sheen: `${(50 - d * 40).toFixed(1)}%`,
focus: f4(1 - near),
};
}
/* ---- Engine: mutable animation state, touched only from effects and handlers ---- */
interface Drag {
id: number;
x0: number;
k: number;
w: number;
base: number;
moved: boolean;
lastPos: number;
lastT: number;
v: number;
}
interface Engine {
pos: number;
vel: number;
target: number;
dragging: boolean;
drag: Drag | null;
glare: number;
glareT: number;
px: number;
py: number;
pxT: number;
pyT: number;
reduced: boolean;
n: number;
loop: boolean;
active: number;
justDragged: boolean;
onChange: ((i: number) => void) | undefined;
wake: () => void;
setActive: (i: number) => void;
}
function makeEngine(index: number, n: number, loop: boolean): Engine {
return {
pos: index, vel: 0, target: index, dragging: false, drag: null,
glare: 0, glareT: 0, px: 0, py: 0, pxT: 0, pyT: 0,
reduced: false, n, loop, active: index, justDragged: false,
onChange: undefined, wake: () => {}, setActive: () => {},
};
}
function paint(E: Engine, cards: (HTMLDivElement | null)[], faces: (HTMLDivElement | null)[], stage: HTMLDivElement | null) {
for (let i = 0; i < E.n; i++) {
const el = cards[i];
if (!el) continue;
let d = i - E.pos;
if (E.loop) d -= E.n * Math.round(d / E.n);
const p = pose(d, E.n, E.loop);
const st = el.style;
st.transform = p.transform;
st.opacity = p.opacity;
st.zIndex = p.zIndex;
st.visibility = p.visibility;
st.setProperty("--dim", p.dim);
st.setProperty("--sheen", p.sheen);
st.setProperty("--focus", p.focus);
const face = faces[i];
if (face) face.style.filter = p.filter;
}
if (stage) {
stage.style.perspectiveOrigin = `${(50 + E.px * 7).toFixed(2)}% ${(42 + E.py * 5).toFixed(2)}%`;
stage.style.setProperty("--glare", E.glare.toFixed(3));
}
}
/** Past the ends (loop off) the position resists like a rubber band. */
function rubber(p: number, max: number) {
const r = (x: number) => 0.55 * (1 - Math.exp(-x * 1.4));
return p < 0 ? -r(-p) : p > max ? max + r(p - max) : p;
}
/* ---- Default card art ---------------------------------------------------- */
const PALETTES: [string, string][] = [
["#ff7a59", "#ffc46b"],
["#4cc9f0", "#5b6cff"],
["#c08bff", "#ff5fa2"],
["#b8f35c", "#19b9a6"],
["#ffd166", "#ef476f"],
["#7ef0d2", "#4a8cff"],
];
const FALLBACK_PALETTE: [string, string] = ["#ff7a59", "#ffc46b"];
function Art({ variant, a, b, uid }: { variant: number; a: string; b: string; uid: string }) {
const g = `${uid}g`;
const o = `${uid}o`;
const m = `${uid}m`;
return (
<svg aria-hidden className="absolute inset-0 h-full w-full" viewBox="0 0 100 130" preserveAspectRatio="xMidYMid slice">
<defs>
<linearGradient id={g} x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stopColor={a} />
<stop offset="1" stopColor={b} />
</linearGradient>
<radialGradient id={o} cx="50%" cy="42%" r="58%">
<stop offset="0" stopColor={a} stopOpacity="0.95" />
<stop offset="0.55" stopColor={b} stopOpacity="0.55" />
<stop offset="1" stopColor={b} stopOpacity="0" />
</radialGradient>
<mask id={m}>
<rect width="100" height="130" fill="#fff" />
<circle cx="72" cy="36" r="30" fill="#000" />
</mask>
</defs>
{variant === 0 && (
<g>
<circle cx="58" cy="46" r="36" fill={`url(#${o})`} />
<g fill="none" stroke="#fff" strokeOpacity="0.2" strokeWidth="0.35">
<circle cx="58" cy="46" r="14" />
<circle cx="58" cy="46" r="24" />
<circle cx="58" cy="46" r="35" />
<circle cx="58" cy="46" r="48" />
</g>
</g>
)}
{variant === 1 && (
<g fill="none" strokeLinecap="round">
<path d="M-8 92 C 20 30, 62 108, 110 38" stroke={`url(#${g})`} strokeWidth="13" strokeOpacity="0.95" />
<path d="M-8 80 C 22 18, 64 96, 110 26" stroke="#fff" strokeWidth="0.5" strokeOpacity="0.4" />
<path d="M-8 106 C 20 46, 62 124, 110 54" stroke={`url(#${g})`} strokeWidth="5" strokeOpacity="0.5" />
</g>
)}
{variant === 2 && (
<g transform="rotate(-14 50 50)">
<rect x="6" y="22" width="76" height="11" rx="5.5" fill={`url(#${g})`} />
<rect x="22" y="38" width="70" height="11" rx="5.5" fill={`url(#${g})`} fillOpacity="0.75" />
<rect x="2" y="54" width="58" height="11" rx="5.5" fill={`url(#${g})`} fillOpacity="0.55" />
<rect x="30" y="70" width="64" height="11" rx="5.5" fill={`url(#${g})`} fillOpacity="0.35" />
</g>
)}
{variant === 3 && (
<g>
<circle cx="56" cy="46" r="36" fill={`url(#${g})`} mask={`url(#${m})`} />
<circle cx="56" cy="46" r="42" fill="none" stroke="#fff" strokeOpacity="0.16" strokeWidth="0.35" />
</g>
)}
</svg>
);
}
function DefaultFace({ title, index, total, uid }: { title: string; index: number; total: number; uid: string }) {
const [a, b] = PALETTES[index % PALETTES.length] ?? FALLBACK_PALETTE;
const num = String(index + 1).padStart(2, "0");
return (
<div className="absolute inset-0">
<div
aria-hidden
className="absolute inset-0"
style={{
background: `radial-gradient(90% 62% at 72% 14%, ${a}44, transparent 70%), radial-gradient(80% 50% at 6% 100%, ${b}33, transparent 72%)`,
}}
/>
<Art variant={index % 4} a={a} b={b} uid={uid} />
<div aria-hidden className="absolute inset-0" style={{ background: "linear-gradient(to top, rgba(8,9,12,0.94) 0%, rgba(8,9,12,0.55) 34%, rgba(8,9,12,0) 62%)" }} />
<div
className="absolute inset-x-[8%] top-[6%] flex items-center justify-between font-medium text-white/75"
style={{ fontFamily: "ui-monospace, SFMono-Regular, Menlo, monospace", fontSize: "max(10px, calc(var(--cc-w) * 0.044))", letterSpacing: "0.16em" }}
>
<span>{num}</span>
<span
className="flex items-center justify-center rounded-full text-white/85"
style={{ width: "calc(var(--cc-w) * 0.11)", height: "calc(var(--cc-w) * 0.11)", background: "rgba(255,255,255,0.1)", boxShadow: "inset 0 0 0 1px rgba(255,255,255,0.22)" }}
>
<svg aria-hidden viewBox="0 0 16 16" width="52%" height="52%" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
<path d="M4.5 11.5 11.5 4.5M5.5 4.5h6v6" />
</svg>
</span>
</div>
<div className="absolute inset-x-[8%] bottom-[7%] text-white">
<div className="break-words font-semibold" style={{ fontSize: "calc(var(--cc-w) * 0.13)", lineHeight: 1.04, letterSpacing: "-0.035em" }}>
{title}
</div>
<div
className="mt-[4%] flex items-center gap-[3%] text-white/55"
style={{ fontFamily: "ui-monospace, SFMono-Regular, Menlo, monospace", fontSize: "max(9px, calc(var(--cc-w) * 0.038))", letterSpacing: "0.18em" }}
>
<span className="h-px flex-1" style={{ background: "rgba(255,255,255,0.22)" }} />
<span>
{num} / {String(total).padStart(2, "0")}
</span>
</div>
</div>
</div>
);
}
/* ---- Component ----------------------------------------------------------- */
const RING =
"focus-visible:!outline-2 focus-visible:!outline-offset-2 focus-visible:!outline-[#5b9bff]";
const BTN =
"flex h-10 w-10 shrink-0 cursor-pointer items-center justify-center rounded-full bg-transparent transition-[background-color,transform,opacity] duration-200 hover:bg-[color-mix(in_srgb,currentColor_12%,transparent)] active:scale-90 disabled:cursor-default disabled:opacity-30 disabled:hover:bg-transparent disabled:active:scale-100 focus-visible:!rounded-full";
export function CardCarousel3D({
cards = DEFAULT_CARDS,
renderCard,
defaultIndex = 0,
onChange,
loop = true,
label = "Card carousel",
className = "",
}: CardCarousel3DProps) {
const n = cards.length;
const looping = loop && n >= 3;
const uid = useId().replace(/[^a-zA-Z0-9]/g, "");
const [initial] = useState(() => clamp(Math.round(defaultIndex), 0, Math.max(0, n - 1)));
const [active, setActive] = useState(initial);
const eng = useRef<Engine>(makeEngine(initial, n, looping));
const stageRef = useRef<HTMLDivElement>(null);
const rootRef = useRef<HTMLDivElement>(null);
const cardEls = useRef<(HTMLDivElement | null)[]>([]);
const faceEls = useRef<(HTMLDivElement | null)[]>([]);
/* The one animation loop: spring, pointer smoothing, paint. Sleeps at rest. */
useEffect(() => {
const E = eng.current;
let raf = 0;
let last = 0;
const frame = (t: number) => {
raf = 0;
const dt = last ? Math.min(0.05, (t - last) / 1000) : 1 / 60;
last = t;
if (!E.dragging) {
if (E.reduced) {
E.pos = E.target;
E.vel = 0;
} else {
let rem = dt;
while (rem > 1e-6) {
const h = Math.min(rem, 1 / 120);
E.vel += (SPRING_K * (E.target - E.pos) - SPRING_C * E.vel) * h;
E.pos += E.vel * h;
rem -= h;
}
}
}
const gk = 1 - Math.exp(-dt * 9);
E.glare += (E.glareT - E.glare) * gk;
E.px += (E.pxT - E.px) * gk;
E.py += (E.pyT - E.py) * gk;
const settled =
!E.dragging && Math.abs(E.target - E.pos) < 0.0004 && Math.abs(E.vel) < 0.004 &&
Math.abs(E.glareT - E.glare) < 0.004 && Math.abs(E.pxT - E.px) < 0.003 && Math.abs(E.pyT - E.py) < 0.003;
if (settled) {
E.pos = E.target;
E.vel = 0;
E.glare = E.glareT;
E.px = E.pxT;
E.py = E.pyT;
}
paint(E, cardEls.current, faceEls.current, stageRef.current);
const idx = E.loop ? mod(Math.round(E.pos), E.n) : clamp(Math.round(E.pos), 0, Math.max(0, E.n - 1));
if (idx !== E.active) {
E.active = idx;
E.setActive(idx);
E.onChange?.(idx);
}
if (settled) last = 0;
else raf = requestAnimationFrame(frame);
};
E.wake = () => {
if (!raf) raf = requestAnimationFrame(frame);
};
E.setActive = setActive;
paint(E, cardEls.current, faceEls.current, stageRef.current);
return () => {
cancelAnimationFrame(raf);
raf = 0;
E.wake = () => {};
};
}, []);
/* Props that the loop reads. */
useEffect(() => {
const E = eng.current;
E.n = n;
E.loop = looping;
E.onChange = onChange;
if (!looping) {
E.target = clamp(Math.round(E.target), 0, Math.max(0, n - 1));
E.pos = clamp(E.pos, 0, Math.max(0, n - 1));
}
paint(E, cardEls.current, faceEls.current, stageRef.current);
E.wake();
}, [n, looping, onChange]);
/* Reduced motion: no spring, no parallax; cards jump into place. */
useEffect(() => {
const E = eng.current;
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
const sync = () => {
E.reduced = mq.matches;
if (mq.matches) {
E.glareT = 0;
E.pxT = 0;
E.pyT = 0;
}
E.wake();
};
sync();
mq.addEventListener("change", sync);
return () => mq.removeEventListener("change", sync);
}, []);
/* A horizontal trackpad swipe steps one card per gesture. */
useEffect(() => {
const stage = stageRef.current;
if (!stage) return;
const E = eng.current;
let acc = 0;
let armed = true;
let lastT = 0;
const onWheel = (e: WheelEvent) => {
if (Math.abs(e.deltaX) <= Math.abs(e.deltaY) * 1.2) return;
e.preventDefault();
if (e.timeStamp - lastT > 140) {
armed = true;
acc = 0;
}
lastT = e.timeStamp;
if (!armed) return;
acc += e.deltaX;
if (Math.abs(acc) > 36) {
step(E, acc > 0 ? 1 : -1);
armed = false;
acc = 0;
}
};
stage.addEventListener("wheel", onWheel, { passive: false });
return () => stage.removeEventListener("wheel", onWheel);
}, []);
/* Keyboard focus follows the front card, so arrow keys keep working from where you were. */
useEffect(() => {
const at = document.activeElement;
if (!at || !rootRef.current?.contains(at)) return;
const els = cardEls.current;
if (els.includes(at as HTMLDivElement)) els[active]?.focus({ preventScroll: true });
}, [active]);
/* ---- handlers (only ever run from events) ---- */
const onPointerDown = (e: PointerEvent<HTMLDivElement>) => {
const E = eng.current;
const stage = stageRef.current;
if (!stage || n < 2 || (e.pointerType === "mouse" && e.button !== 0)) return;
const rect = stage.getBoundingClientRect();
E.drag = {
id: e.pointerId,
x0: e.clientX,
k: rect.width ? stage.offsetWidth / rect.width : 1,
w: cardEls.current[E.active]?.offsetWidth || 200,
base: E.pos,
moved: false,
lastPos: E.pos,
lastT: e.timeStamp,
v: 0,
};
};
const onPointerMove = (e: PointerEvent<HTMLDivElement>) => {
const E = eng.current;
const stage = stageRef.current;
const dr = E.drag;
if (dr && dr.id === e.pointerId) {
const dx = (e.clientX - dr.x0) * dr.k;
if (!dr.moved && Math.abs(dx) > DRAG_SLOP) {
dr.moved = true;
E.dragging = true;
E.glareT = 0;
stage?.setPointerCapture(e.pointerId);
}
if (dr.moved) {
const raw = dr.base - dx / (dr.w * STEP);
const p = E.loop ? raw : rubber(raw, Math.max(0, E.n - 1));
const dt = (e.timeStamp - dr.lastT) / 1000;
if (dt > 0.001) {
dr.v = dr.v * 0.6 + ((p - dr.lastPos) / dt) * 0.4;
dr.lastPos = p;
dr.lastT = e.timeStamp;
}
E.pos = p;
E.target = p;
E.vel = 0;
E.wake();
return;
}
}
if (e.pointerType === "touch" || E.reduced || !stage) return;
const rect = stage.getBoundingClientRect();
const front = cardEls.current[E.active]?.getBoundingClientRect();
E.pxT = clamp(((e.clientX - rect.left) / rect.width) * 2 - 1, -1, 1);
E.pyT = clamp(((e.clientY - rect.top) / rect.height) * 2 - 1, -1, 1);
if (front && front.width) {
stage.style.setProperty("--mx", `${(((e.clientX - front.left) / front.width) * 100).toFixed(1)}%`);
stage.style.setProperty("--my", `${(((e.clientY - front.top) / front.height) * 100).toFixed(1)}%`);
}
E.glareT = 1;
E.wake();
};
const endPointer = (e: PointerEvent<HTMLDivElement>) => {
const E = eng.current;
const dr = E.drag;
if (!dr || dr.id !== e.pointerId) return;
E.drag = null;
if (!dr.moved) return;
E.dragging = false;
E.justDragged = true;
window.setTimeout(() => {
E.justDragged = false;
}, 60);
const v = e.type === "pointercancel" || e.timeStamp - dr.lastT > 90 ? 0 : clamp(dr.v, -9, 9);
let t = Math.round(E.pos + v * THROW);
if (!E.loop) t = clamp(t, 0, Math.max(0, E.n - 1));
E.target = t;
E.vel = v;
E.wake();
};
const onPointerLeave = (e: PointerEvent<HTMLDivElement>) => {
const E = eng.current;
if (e.pointerType === "touch") return;
E.glareT = 0;
E.pxT = 0;
E.pyT = 0;
E.wake();
};
const onCardClick = (i: number) => {
const E = eng.current;
if (E.justDragged) return;
goTo(E, i);
};
const onKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
const E = eng.current;
if (e.altKey || e.ctrlKey || e.metaKey) return;
if (e.key === "ArrowRight" || e.key === "PageDown") step(E, 1);
else if (e.key === "ArrowLeft" || e.key === "PageUp") step(E, -1);
else if (e.key === "Home") goTo(E, 0);
else if (e.key === "End") goTo(E, n - 1);
else return;
e.preventDefault();
};
const wrapD = (d: number) => (looping ? d - n * Math.round(d / n) : d);
const dots = n <= 7;
return (
<div
ref={rootRef}
role="group"
aria-roledescription="carousel"
aria-label={label}
onKeyDown={onKeyDown}
className={`relative flex w-full select-none flex-col items-center gap-7 overflow-x-clip ${className}`}
style={{ containerType: "inline-size" }}
>
<div
ref={stageRef}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={endPointer}
onPointerCancel={endPointer}
onPointerLeave={onPointerLeave}
className="relative w-full cursor-grab active:cursor-grabbing"
style={
{
"--cc-w": "var(--cc3d-card-w, clamp(150px, 44cqw, 280px))",
height: "calc(var(--cc-w) * 1.3)",
perspective: "calc(var(--cc-w) * 3.6)",
perspectiveOrigin: "50% 42%",
touchAction: "pan-y",
overscrollBehaviorX: "contain",
} as CSSProperties
}
>
{cards.map((card, i) => {
const isActive = i === active;
const nearby = !isActive && Math.abs(wrapD(i - active)) <= 1;
const P = pose(wrapD(i - initial), n, looping);
return (
<div
key={i}
ref={(el) => {
cardEls.current[i] = el;
}}
role={isActive ? "group" : "button"}
aria-roledescription={isActive ? "slide" : undefined}
aria-label={isActive ? `${card}, ${i + 1} of ${n}` : `Show card: ${card}`}
aria-hidden={!isActive && !nearby ? true : undefined}
tabIndex={isActive || nearby ? 0 : -1}
onClick={isActive ? undefined : () => onCardClick(i)}
onKeyDown={
isActive
? undefined
: (e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
e.stopPropagation();
goTo(eng.current, i);
}
}
}
className={`absolute top-0 ${RING} focus-visible:!rounded-[calc(var(--cc-w)*0.08)] ${isActive ? "" : "cursor-pointer"}`}
style={
{
left: "50%",
width: "var(--cc-w)",
height: "100%",
marginLeft: "calc(var(--cc-w) / -2)",
borderRadius: "calc(var(--cc-w) * 0.08)",
boxShadow: "0 38px 60px -30px rgba(0,0,0,0.7), 0 14px 26px -14px rgba(0,0,0,0.45)",
willChange: "transform",
transform: P.transform,
opacity: P.opacity,
zIndex: P.zIndex,
visibility: P.visibility,
"--dim": P.dim,
"--sheen": P.sheen,
"--focus": P.focus,
} as CSSProperties
}
>
<div
ref={(el) => {
faceEls.current[i] = el;
}}
className="absolute inset-0 overflow-hidden"
style={{ borderRadius: "inherit", background: "#0b0c10", filter: P.filter }}
>
{renderCard ? (
<div className="absolute inset-0">{renderCard(card, i, isActive)}</div>
) : (
<DefaultFace title={card} index={i} total={n} uid={`${uid}c${i}`} />
)}
<div
aria-hidden
className="pointer-events-none absolute inset-0"
style={{ background: "linear-gradient(105deg, transparent calc(var(--sheen) - 24%), rgba(255,255,255,0.09) var(--sheen), transparent calc(var(--sheen) + 24%))" }}
/>
<div
aria-hidden
className="pointer-events-none absolute inset-0"
style={{
background: "radial-gradient(circle at var(--mx, 50%) var(--my, 30%), rgba(255,255,255,0.24), transparent 55%)",
opacity: "calc(var(--glare, 0) * var(--focus, 1))",
}}
/>
<div aria-hidden className="pointer-events-none absolute inset-0" style={{ background: "#05060a", opacity: "var(--dim, 0)" }} />
<div
aria-hidden
className="pointer-events-none absolute inset-0"
style={{ borderRadius: "inherit", boxShadow: "inset 0 0 0 1px rgba(255,255,255,0.13), inset 0 1px 0 rgba(255,255,255,0.28)" }}
/>
</div>
</div>
);
})}
</div>
<div className="flex items-center justify-center gap-1">
<button
type="button"
aria-label="Previous card"
disabled={!looping && active <= 0}
onClick={() => step(eng.current, -1)}
className={`${BTN} ${RING}`}
style={{ boxShadow: "inset 0 0 0 1px color-mix(in srgb, currentColor 28%, transparent)" }}
>
<svg aria-hidden viewBox="0 0 16 16" width="16" height="16" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round">
<path d="M10 3 5 8l5 5" />
</svg>
</button>
{dots ? (
<div role="group" aria-label="Choose card" className="flex items-center px-1">
{cards.map((card, i) => (
<button
key={i}
type="button"
aria-label={`Go to card ${i + 1}: ${card}`}
aria-current={i === active ? "true" : undefined}
onClick={() => goTo(eng.current, i)}
className={`group flex h-10 w-7 cursor-pointer items-center justify-center bg-transparent focus-visible:!rounded-full ${RING}`}
>
<span
className="block h-1.5 rounded-full transition-[width,opacity] duration-300 ease-out group-hover:!opacity-80 motion-reduce:transition-none"
style={{ width: i === active ? 20 : 6, background: "currentColor", opacity: i === active ? 1 : 0.32 }}
/>
</button>
))}
</div>
) : (
<span className="min-w-[8ch] px-2 text-center text-xs tabular-nums tracking-[0.18em] opacity-60" aria-hidden>
{String(active + 1).padStart(2, "0")} / {String(n).padStart(2, "0")}
</span>
)}
<button
type="button"
aria-label="Next card"
disabled={!looping && active >= n - 1}
onClick={() => step(eng.current, 1)}
className={`${BTN} ${RING}`}
style={{ boxShadow: "inset 0 0 0 1px color-mix(in srgb, currentColor 28%, transparent)" }}
>
<svg aria-hidden viewBox="0 0 16 16" width="16" height="16" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round">
<path d="m6 3 5 5-5 5" />
</svg>
</button>
</div>
<span className="sr-only" aria-live="polite">
{n > 0 ? `${cards[active] ?? ""}, ${active + 1} of ${n}` : ""}
</span>
</div>
);
}
/** Move the target one card (wraps when looping, stops at the ends otherwise). */
function step(E: Engine, dir: number) {
if (E.n < 2) return;
const t = Math.round(E.target) + dir;
E.target = E.loop ? t : clamp(t, 0, E.n - 1);
E.wake();
}
/** Move the target to card i by the shortest way round. */
function goTo(E: Engine, i: number) {
if (E.n < 2) return;
const base = Math.round(E.target);
if (E.loop) {
let d = mod(i, E.n) - mod(base, E.n);
d -= E.n * Math.round(d / E.n);
E.target = base + d;
} else {
E.target = clamp(i, 0, E.n - 1);
}
E.wake();
}
Free. Pro unlocks everything, from $27/month.
Details
About this pattern
A 3D carousel works when the depth comes from one idea, not from stacked effects. Here the idea is a single continuous position: 2.0 means card 2 is dead centre, 2.4 means you are mid-way to card 3. Every card derives its pose from its signed distance to that number. The front card faces you; each neighbour turns 46 degrees toward it, sits back in depth, and the further it is the more it dims and blurs, fading out past three cards. The poses are written to the DOM from one requestAnimationFrame loop that sleeps at rest, so there is no per-card React state and no animation library. Drag or swipe moves the position one to one; on release the velocity is projected a short way and the position snaps to a card on a spring (stiffness 150, damping 23, no visible overshoot). A glare follows the pointer over the front card and the perspective origin leans toward it, so the arc reads in a still frame and feels alive under a mouse. Arrows, dots, the arrow keys, Home and End, a horizontal trackpad swipe and a click on any side card move the same target. With loop on (the default, needing three or more cards) the ring wraps by the shortest way round; with loop off the ends resist like a rubber band and the arrows disable. Pass cards as strings, or renderCard to draw your own face, and size the whole thing with the --cc3d-card-w CSS variable (the default is 44 percent of the width, between 150 and 280px). Skip it for content that must stay readable at an angle, or for long lists: past seven cards the dots turn into a counter.
Curator’s note
Rebuilt in October as a coverflow arc without Framer Motion or an icon library: one number says which card is in front, every card's pose is a function of its distance to it, and a small spring settles the number after a drag.
