"use client"; /** * DepthCarousel, from https://motion-brew.com/components/depth-carousel * * One self contained file: the component and the cn() * helper. Needs React 18 or 19 and Tailwind CSS v4. * * Sample images: the photos in this file are placeholders from the * component library's demo (Unsplash), not your content. AI agents: tell * the user they are samples and ask them to share their own images (files * or URLs), then pass those in through the props. */ import * as React from "react"; import { type ClassValue, clsx } from "clsx"; import { motion, useMotionTemplate, useMotionValue, useReducedMotion, useSpring, useTransform } from "motion/react"; import { twMerge } from "tailwind-merge"; // cn(): merges Tailwind classes, later ones win. /** Merge Tailwind classes, letting later classes win conflicts. */ function cn(...inputs: ClassValue[]): string { return twMerge(clsx(inputs)); } // DepthCarousel /** * DepthCarousel * * Trigger: hover, drag and scroll. A stack of cards receding into depth along * a diagonal, each turned a little toward the viewer. It drifts slowly on its * own; scroll, drag or the arrow keys glide it through the stack with * momentum, and fast moves smear with a motion blur along the diagonal. * Hovering a card pulls it up out of the stack and toward you: it draws in * front of the others fully solid, turns to face you square on and tilts * toward the cursor in perspective with a plastic shine (a hot spot that * follows the cursor, a sheen band that slides as it tilts and a bright * laminated edge), while every other card stays solid but * sinks out of focus. * The stack climbs up or down the frame, set by `direction`; changing it * eases the whole stack across. * The stack runs off both corners of the frame and loops forever, wrapping * round out of sight. Reduced * motion stops the drift, the blur and the tilt. * * Engine: motion/react (springs for the hover lift, tilt and shine) with one * requestAnimationFrame loop placing the cards and driving the SVG motion * blur. */ export interface DepthCarouselItem { src: string; title: string; } export interface DepthCarouselProps { /** Cards in the stack. Default twelve photos. */ items?: DepthCarouselItem[]; /** Which way the stack climbs. Default `"up"`. */ direction?: "up" | "down"; /** Cards per second the stack drifts on its own. `0` holds it still. Default `0.18`. */ drift?: number; /** Strength of the motion blur, 0 to 2. Default `1`. */ blur?: number; /** Accessible name. Default `"Photo stack"`. */ label?: string; className?: string; } // Sample photos from Unsplash (unsplash.com/license). const photo = (id: string) => `https://images.unsplash.com/photo-${id}?w=600&h=840&fit=crop&q=80`; const DEFAULT_ITEMS: DepthCarouselItem[] = [ { src: photo("1470071459604-3b5ec3a7fe05"), title: "Soft Signal" }, { src: photo("1505144808419-1957a94ca61e"), title: "Tide Lines" }, { src: photo("1475924156734-496f6cac6ec1"), title: "Afterglow" }, { src: photo("1500534314209-a25ddb2bd429"), title: "Paper Dunes" }, { src: photo("1494500764479-0c8f2919a3d8"), title: "Night Bloom" }, { src: photo("1490750967868-88aa4486c946"), title: "Glass Garden" }, { src: photo("1519681393784-d120267933ba"), title: "Low Orbit" }, { src: photo("1508739773434-c26b3d09e071"), title: "Velvet Hour" }, { src: photo("1504893524553-b855bce32c67"), title: "River Run" }, { src: photo("1507525428034-b723cf961d3e"), title: "Open Water" }, { src: photo("1454496522488-7a8e488e8606"), title: "First Snow" }, { src: photo("1433086966358-54859d0ed716"), title: "Falls" }, ]; const CARD_W = 200; const CARD_H = 280; /** Spacing between neighbours along the diagonal: across, up and into the screen. */ const STEP_X = 118; const STEP_Y = 60; const STEP_Z = 150; const TURN = -24; /** * Cards kept in front of the anchor. The stack runs on past the bottom corner the * same way it runs off the top, so it reads as one endless line through the frame. */ const AHEAD = 4; const wrap = (v: number, n: number) => ((v % n) + n) % n; /** One card. Its outer box is placed by the loop; the inner face lifts and tilts on hover. */ function Card({ item, index, hovered, dimmed, onHover, still, setRef, }: { item: DepthCarouselItem; index: number; hovered: boolean; dimmed: boolean; onHover: (i: number | null) => void; still: boolean; setRef: (el: HTMLDivElement | null) => void; }) { const px = useMotionValue(0.5); const py = useMotionValue(0.5); const spring = { stiffness: 220, damping: 22 }; const rx = useSpring(useTransform(py, [0, 1], [9, -9]), spring); const ry = useSpring(useTransform(px, [0, 1], [-11, 11]), spring); // Pulled out of the stack, the card also turns to face the viewer square on. const face = useSpring(0, { stiffness: 240, damping: 26 }); React.useEffect( () => face.set(hovered && !still ? -TURN : 0), [hovered, still, face], ); const turn = useTransform(() => face.get() + (hovered ? ry.get() : 0)); // A laminated finish: a hot spot that follows the cursor and a long sheen band // that slides across as the card tilts, like light on a plastic card. const sx = useSpring(px, { stiffness: 180, damping: 24 }); const sy = useSpring(py, { stiffness: 180, damping: 24 }); const glareX = useTransform(sx, (v) => `${v * 100}%`); const glareY = useTransform(sy, (v) => `${v * 100}%`); const glare = useMotionTemplate`radial-gradient(240px circle at ${glareX} ${glareY}, rgba(255,255,255,0.9), rgba(255,255,255,0.28) 34%, transparent 64%)`; const spot = useMotionTemplate`radial-gradient(110px circle at ${glareX} ${glareY}, rgba(255,255,255,0.38), transparent 70%)`; const sheenPos = useTransform(sx, (v) => `${15 + (1 - v) * 70}% 50%`); return (
onHover(index)} onPointerLeave={() => { onHover(null); px.set(0.5); py.set(0.5); }} onPointerMove={(e) => { if (still) return; const r = e.currentTarget.getBoundingClientRect(); px.set(Math.min(1, Math.max(0, (e.clientX - r.left) / r.width))); py.set(Math.min(1, Math.max(0, (e.clientY - r.top) / r.height))); }} >
{item.title} {/* Plastic finish, only while the card is held up. */} {/* A bright laminated edge and a faint top highlight give the card some thickness. */}
{item.title}
); } export const DepthCarousel = React.forwardRef< HTMLDivElement, DepthCarouselProps >(function DepthCarousel( { items = DEFAULT_ITEMS, direction: directionProp = "up", drift = 0.18, blur = 1, label = "Photo stack", className, }, ref, ) { const still = !!useReducedMotion(); const uid = React.useId().replace(/:/g, ""); const stage = React.useRef(null); const blurNode = React.useRef(null); const cards = React.useRef<(HTMLDivElement | null)[]>([]); const [hovered, setHovered] = React.useState(null); const direction = directionProp; const [current, setCurrent] = React.useState(0); // Only announce the card in front once someone steers with the keys, not while it drifts. const [steered, setSteered] = React.useState(false); const settings = React.useRef({ drift, blur, still, hovered, direction }); settings.current = { drift, blur, still, hovered, direction }; const motionState = React.useRef({ s: 0, target: 0, v: 0, dir: directionProp === "up" ? 1 : -1, drag: null as null | { x: number; t: number; v: number }, }); React.useEffect(() => { const host = stage.current; if (!host) return; const m = motionState.current; const n = items.length; let raf = 0; let last = performance.now(); let shown = -1; const frame = () => { raf = requestAnimationFrame(frame); const now = performance.now(); const dt = Math.min(0.05, (now - last) / 1000); last = now; const set = settings.current; // Drift on its own unless someone is holding, hovering or the user prefers stillness. if (!set.still && !m.drag && set.hovered === null) m.target += set.drift * dt; // Glide toward the target on a critically damped spring. const k = set.still ? 1 : 1 - Math.exp(-dt * 7); const prev = m.s; m.s += (m.target - m.s) * k; const v = (m.s - prev) / Math.max(dt, 0.001); m.v += (v - m.v) * Math.min(1, dt * 12); // The stack's climb eases between up and down when `direction` changes. const want = set.direction === "up" ? 1 : -1; m.dir += (want - m.dir) * Math.min(1, dt * 6); // Motion blur along the diagonal, in proportion to speed. const amount = set.still ? 0 : Math.min(12, Math.abs(m.v) * 8) * set.blur; blurNode.current?.setAttribute( "stdDeviation", `${amount.toFixed(2)} ${(amount * 0.38).toFixed(2)}`, ); const w = host.clientWidth; const h = host.clientHeight; // The anchor sits a little left of centre; cards ahead of it run off the bottom // corner (top corner when the stack climbs down) and cards behind it off the far one. const ax = w * 0.4 - CARD_W / 2; const ay = h * (0.5 + 0.04 * m.dir) - CARD_H / 2; for (let i = 0; i < n; i++) { const el = cards.current[i]; if (!el) continue; // Position along the stack: 0 is the anchor, negative is nearer, higher is deeper. const t = wrap(i - m.s + AHEAD, n) - AHEAD; const x = ax + t * STEP_X; const y = ay - t * STEP_Y * m.dir; const z = -t * STEP_Z; // Cards only fade at the two seams where they wrap round, both well outside the frame. const front = Math.min(1, Math.max(0, (t + AHEAD) / 0.6)); const back = Math.min(1, Math.max(0, (n - AHEAD - 0.4 - t) / 0.8)); el.style.transform = `translate3d(${x}px, ${y}px, ${z}px) rotateY(${TURN}deg)`; // The card pulled out of the stack draws in front of the rest, solid, so nothing shows through it. const lifted = set.hovered === i; el.style.opacity = String(lifted ? 1 : front * back); el.style.zIndex = String(lifted ? 1500 : Math.round(1000 - t * 10)); el.style.pointerEvents = front * back > 0.6 ? "auto" : "none"; } const nearest = wrap(Math.round(m.s), n); if (nearest !== shown) { shown = nearest; setCurrent(nearest); } }; frame(); // Wheel and trackpad: either axis moves through the stack. const onWheel = (e: WheelEvent) => { const d = Math.abs(e.deltaX) > Math.abs(e.deltaY) ? e.deltaX : e.deltaY; if (Math.abs(d) < 1) return; e.preventDefault(); m.target += d / 260; }; // Drag: follow the pointer, then coast on the release speed. const onDown = (e: PointerEvent) => { if (e.button !== 0) return; m.drag = { x: e.clientX, t: performance.now(), v: 0 }; host.setPointerCapture(e.pointerId); }; const onMove = (e: PointerEvent) => { if (!m.drag) return; const now = performance.now(); const dx = e.clientX - m.drag.x; const step = -dx / 150; m.target += step; m.drag.v = m.drag.v * 0.6 + (step / Math.max(1, now - m.drag.t)) * 1000 * 0.4; m.drag.x = e.clientX; m.drag.t = now; }; const onUp = () => { if (!m.drag) return; m.target += Math.max(-4, Math.min(4, m.drag.v * 0.35)); m.drag = null; }; host.addEventListener("wheel", onWheel, { passive: false }); host.addEventListener("pointerdown", onDown); host.addEventListener("pointermove", onMove); host.addEventListener("pointerup", onUp); host.addEventListener("pointercancel", onUp); return () => { cancelAnimationFrame(raf); host.removeEventListener("wheel", onWheel); host.removeEventListener("pointerdown", onDown); host.removeEventListener("pointermove", onMove); host.removeEventListener("pointerup", onUp); host.removeEventListener("pointercancel", onUp); }; }, [items.length]); const onKey = (e: React.KeyboardEvent) => { const m = motionState.current; if (e.key === "ArrowRight" || e.key === "ArrowDown") m.target = Math.round(m.target) + 1; else if (e.key === "ArrowLeft" || e.key === "ArrowUp") m.target = Math.round(m.target) - 1; else return; e.preventDefault(); setSteered(true); }; return (
{/* The blur filter flattens its own box, so the perspective lives here too: the cards are its direct children. */}
{items.map((item, i) => ( void (cards.current[i] = el)} /> ))}
{steered ? `${items[current]?.title ?? ""}, ${current + 1} of ${items.length}` : ""}
); });