A coverflow gallery of framed art prints on a dark wall. The centre print faces the viewer while its neighbours turn away in 3D, step back and darken, each with a soft floor reflection. Prints move on springs, the caption cross-fades, and a light behind the stage takes the colour of the print in focus. It advances on its own, and responds to the arrows, the dots, a click on any side print, a swipe and the arrow keys. On phones the prints shrink and the side ones run off the edges.
import { useCallback, useEffect, useRef, useState } from "react";
import { AnimatePresence, motion, useInView, useReducedMotion } from "framer-motion";
type ArtKind = "sun" | "tide" | "monolith" | "field" | "bloom" | "signal" | "engine";
// The prints in the gallery. `glow` tints the light behind the centre print.
const prints: { art: ArtKind; title: string; artist: string; edition: string; price: string; glow: string }[] = [
{ art: "sun", title: "Low Sun", artist: "Ines Calloway", edition: "Edition of 80", price: "$140", glow: "#f97316" },
{ art: "tide", title: "Tidal Count", artist: "Bram Oyelaran", edition: "Edition of 120", price: "$110", glow: "#38bdf8" },
{ art: "monolith", title: "Monolith No. 4", artist: "Suki Varga", edition: "Edition of 50", price: "$220", glow: "#e7dfd0" },
{ art: "field", title: "Field Notes", artist: "Mateo Lindahl", edition: "Edition of 100", price: "$125", glow: "#4ade80" },
{ art: "bloom", title: "Night Bloom", artist: "Ayo Fenwick", edition: "Edition of 60", price: "$180", glow: "#e879f9" },
{ art: "signal", title: "Signal Yellow", artist: "Dara Mossberg", edition: "Edition of 150", price: "$95", glow: "#facc15" },
{ art: "engine", title: "Quiet Engine", artist: "Lior Castellan", edition: "Edition of 75", price: "$160", glow: "#2dd4bf" },
];
const AUTOPLAY_MS = 2600;
const SPRING = { type: "spring", stiffness: 170, damping: 24, mass: 0.9 } as const;
// Where a print sits, by how many steps it is from the centre.
function pose(d: number) {
const a = Math.abs(d);
const s = Math.sign(d);
if (a === 0) return { x: "0%", rotateY: 0, scale: 1, z: 0, opacity: 1, shade: 0 };
if (a === 1) return { x: `${s * 70}%`, rotateY: -s * 46, scale: 0.86, z: -140, opacity: 1, shade: 0.4 };
if (a === 2) return { x: `${s * 124}%`, rotateY: -s * 54, scale: 0.76, z: -260, opacity: 1, shade: 0.62 };
return { x: `${s * 160}%`, rotateY: -s * 58, scale: 0.66, z: -360, opacity: 0, shade: 0.8 };
}
function Art({ kind }: { kind: ArtKind }) {
const p = { viewBox: "0 0 400 500", preserveAspectRatio: "xMidYMid slice", "aria-hidden": true, className: "block h-full w-full" } as const;
switch (kind) {
case "sun":
return (
<svg {...p}>
<defs><linearGradient id="cf-sun" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stopColor="#be123c" /><stop offset="0.55" stopColor="#fb923c" /><stop offset="1" stopColor="#fde68a" /></linearGradient></defs>
<rect width="400" height="500" fill="url(#cf-sun)" />
<circle cx="200" cy="250" r="104" fill="#fff7ed" />
<rect y="318" width="400" height="182" fill="#431407" />
{[0, 1, 2, 3, 4].map((i) => <rect key={i} x={110 + i * 14} y={336 + i * 30} width={180 - i * 28} height={10} rx="5" fill="#fdba74" opacity={0.9 - i * 0.15} />)}
</svg>
);
case "tide":
return (
<svg {...p}>
<rect width="400" height="500" fill="#0b1f44" />
{[0, 1, 2, 3, 4, 5, 6, 7].map((i) => <circle key={i} cx="200" cy="520" r={70 + i * 56} fill="none" stroke="#7dd3fc" strokeWidth="14" opacity={0.95 - i * 0.11} />)}
<circle cx="306" cy="92" r="30" fill="#e0f2fe" />
</svg>
);
case "monolith":
return (
<svg {...p}>
<rect width="400" height="500" fill="#e9e1d1" />
<rect x="128" y="110" width="144" height="300" fill="#171717" />
<circle cx="302" cy="128" r="38" fill="#dc2626" />
<rect x="60" y="410" width="280" height="6" fill="#171717" />
<rect x="60" y="430" width="120" height="6" fill="#171717" opacity="0.4" />
</svg>
);
case "field":
return (
<svg {...p}>
<rect width="400" height="500" fill="#14532d" />
<circle cx="262" cy="196" r="118" fill="#facc15" />
{Array.from({ length: 80 }, (_, i) => <circle key={i} cx={56 + (i % 8) * 41} cy={66 + Math.floor(i / 8) * 41} r="7" fill="#dcfce7" opacity={0.92} />)}
</svg>
);
case "bloom":
return (
<svg {...p}>
<rect width="400" height="500" fill="#1e1b4b" />
<g style={{ mixBlendMode: "screen" }}>
<circle cx="160" cy="210" r="120" fill="#f472b6" opacity="0.9" />
<circle cx="250" cy="250" r="120" fill="#818cf8" opacity="0.9" />
<circle cx="190" cy="320" r="120" fill="#22d3ee" opacity="0.8" />
</g>
</svg>
);
case "signal":
return (
<svg {...p}>
<rect width="400" height="500" fill="#facc15" />
{[0, 1, 2, 3, 4, 5, 6, 7, 8, 9].map((i) => <rect key={i} x={-340 + i * 96} y="-80" width="40" height="760" fill="#171717" transform="rotate(32 200 250)" />)}
<circle cx="200" cy="250" r="112" fill="#fafaf9" />
<circle cx="200" cy="250" r="36" fill="#171717" />
</svg>
);
default:
return (
<svg {...p}>
<rect width="400" height="500" fill="#0f766e" />
{[0, 1, 2, 3, 4, 5].map((i) => <rect key={i} x={i % 2 ? 110 : 50} y={62 + i * 66} width="240" height="46" rx="23" fill={["#ccfbf1", "#134e4a", "#fb923c", "#ccfbf1", "#134e4a", "#fde68a"][i]} />)}
</svg>
);
}
}
function Frame({ art, title, number }: { art: ArtKind; title: string; number: string }) {
return (
<div className="bg-[#f6f2ea] p-2.5 shadow-2xl shadow-black/60 sm:p-3">
<div className="aspect-[4/5] overflow-hidden"><Art kind={art} /></div>
<div className="flex items-center justify-between pt-2.5 text-[10px] font-semibold uppercase tracking-[0.16em] text-neutral-700 sm:pt-3">
<span className="truncate">{title}</span>
<span className="flex-none font-mono">{number}</span>
</div>
</div>
);
}
function Arrow({ dir }: { dir: "left" | "right" }) {
return (
<svg viewBox="0 0 20 20" aria-hidden="true" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d={dir === "left" ? "M16 10H4M9 5l-5 5 5 5" : "M4 10h12M11 5l5 5-5 5"} />
</svg>
);
}
export default function CoverflowGalleryWith3dPerspectiveAndAutoplay() {
const n = prints.length;
const reduce = useReducedMotion();
const section = useRef<HTMLElement>(null);
const inView = useInView(section, { amount: 0.3 });
const [active, setActive] = useState(0);
const [held, setHeld] = useState(false);
const [entered, setEntered] = useState(false);
const panned = useRef(0);
const go = useCallback((i: number) => setActive(((i % n) + n) % n), [n]);
useEffect(() => {
const id = window.setTimeout(() => setEntered(true), 900);
return () => window.clearTimeout(id);
}, []);
// Auto-advance, unless the visitor is hovering, focused inside, scrolled away or on another tab.
const playing = inView && !held && !reduce;
useEffect(() => {
if (!playing) return;
const id = window.setInterval(() => {
if (!document.hidden) setActive((a) => (a + 1) % n);
}, AUTOPLAY_MS);
return () => window.clearInterval(id);
}, [playing, n]);
const current = prints[active];
return (
<section ref={section} className="relative overflow-hidden bg-[#0d0c0b] px-5 py-12 text-stone-100 sm:py-14">
<motion.div
aria-hidden="true"
className="pointer-events-none absolute left-1/2 top-1/2 h-[420px] w-[720px] max-w-full -translate-x-1/2 -translate-y-1/2 rounded-full opacity-25 blur-3xl"
animate={{ backgroundColor: current.glow }}
transition={{ duration: reduce ? 0 : 1.2 }}
/>
<div className="relative mx-auto max-w-5xl">
<div className="mx-auto max-w-3xl text-center">
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-amber-300">Autumn editions</p>
<h2 className="mt-3 font-serif text-3xl leading-tight tracking-tight sm:text-[2.6rem]">Seven new prints, numbered and signed.</h2>
</div>
<motion.div
role="group"
aria-roledescription="carousel"
aria-label="Autumn print editions"
tabIndex={0}
className="relative mx-auto mt-8 flex h-[375px] items-start justify-center rounded-lg outline-none focus-visible:ring-2 focus-visible:ring-amber-300 sm:mt-10 sm:h-[505px]"
style={{ perspective: 1100, touchAction: "pan-y" }}
onKeyDown={(e) => {
if (e.key === "ArrowLeft") { e.preventDefault(); go(active - 1); }
if (e.key === "ArrowRight") { e.preventDefault(); go(active + 1); }
}}
onPanEnd={(_, info) => {
if (Math.abs(info.offset.x) < 40) return;
panned.current = Date.now();
go(active + (info.offset.x < 0 ? 1 : -1));
}}
onHoverStart={() => setHeld(true)}
onHoverEnd={() => setHeld(false)}
onFocus={() => setHeld(true)}
onBlur={() => setHeld(false)}
>
{prints.map((p, i) => {
let d = i - active;
if (d > n / 2) d -= n;
if (d < -n / 2) d += n;
const t = pose(d);
const far = Math.abs(d) > 2;
return (
<motion.button
key={p.art}
type="button"
tabIndex={-1}
aria-hidden={far}
aria-label={d === 0 ? `${p.title} by ${p.artist}, in focus` : `Show ${p.title} by ${p.artist}`}
onClick={() => { if (Date.now() - panned.current > 250) go(i); }}
className={`absolute top-0 w-52 text-left outline-none sm:w-72 ${d === 0 ? "cursor-default" : "cursor-pointer"} ${far ? "pointer-events-none" : ""}`}
style={{ zIndex: 10 - Math.abs(d) }}
initial={reduce ? false : { x: "0%", rotateY: 0, scale: 0.6, z: -300, opacity: 0 }}
animate={{ x: t.x, rotateY: t.rotateY, scale: t.scale, z: t.z, opacity: t.opacity }}
transition={reduce ? { duration: 0 } : { ...SPRING, delay: entered ? 0 : 0.08 + Math.abs(d) * 0.09 }}
>
<Frame art={p.art} title={p.title} number={`${String(i + 1).padStart(2, "0")}/${String(n).padStart(2, "0")}`} />
<motion.span aria-hidden="true" className="absolute inset-0 bg-black" animate={{ opacity: t.shade }} transition={{ duration: reduce ? 0 : 0.5 }} />
{/* Floor reflection */}
<span
aria-hidden="true"
className="pointer-events-none absolute inset-x-0 top-full mt-1 block opacity-30"
style={{ transform: "scaleY(-1)", WebkitMaskImage: "linear-gradient(to top, #000, transparent 30%)", maskImage: "linear-gradient(to top, #000, transparent 30%)" }}
>
<Frame art={p.art} title={p.title} number="" />
</span>
</motion.button>
);
})}
</motion.div>
<div className="relative z-20 mx-auto flex max-w-md flex-col items-center text-center">
<div className="h-[76px]" aria-live={playing ? "off" : "polite"}>
<AnimatePresence mode="wait" initial={false}>
<motion.div
key={current.art}
initial={reduce ? false : { opacity: 0, y: 10, filter: "blur(6px)" }}
animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
exit={reduce ? { opacity: 0 } : { opacity: 0, y: -8, filter: "blur(6px)" }}
transition={{ duration: 0.28, ease: "easeOut" }}
>
<h3 className="font-serif text-2xl tracking-tight sm:text-3xl">{current.title}</h3>
<p className="mt-1.5 text-sm text-stone-400">
{current.artist} <span aria-hidden="true" className="mx-1.5 text-stone-600">/</span> {current.edition} <span aria-hidden="true" className="mx-1.5 text-stone-600">/</span> <span className="font-semibold text-amber-300">{current.price}</span>
</p>
</motion.div>
</AnimatePresence>
</div>
<div className="mt-3 flex items-center gap-4">
<button type="button" aria-label="Previous print" onClick={() => go(active - 1)} className="flex h-11 w-11 items-center justify-center rounded-full border border-white/15 text-stone-200 transition hover:border-amber-300 hover:text-amber-300 focus:outline-none focus-visible:ring-2 focus-visible:ring-amber-300">
<Arrow dir="left" />
</button>
<div className="flex items-center gap-1" role="group" aria-label="Choose a print">
{prints.map((p, i) => (
<button key={p.art} type="button" aria-label={`Show ${p.title}`} aria-current={i === active} onClick={() => go(i)} className="group flex h-10 items-center px-1 focus:outline-none">
<span className={`relative block h-1 overflow-hidden rounded-full transition-all duration-500 group-focus-visible:ring-2 group-focus-visible:ring-amber-300 group-focus-visible:ring-offset-2 group-focus-visible:ring-offset-[#0d0c0b] ${i === active ? "w-9 bg-white/25" : "w-2.5 bg-white/25 group-hover:bg-white/60"}`}>
{i === active && (
<motion.span
key={`${active}-${playing}`}
className="absolute inset-0 origin-left rounded-full bg-amber-300"
initial={{ scaleX: playing ? 0 : 1 }}
animate={{ scaleX: 1 }}
transition={{ duration: playing ? AUTOPLAY_MS / 1000 : 0, ease: "linear" }}
/>
)}
</span>
</button>
))}
</div>
<button type="button" aria-label="Next print" onClick={() => go(active + 1)} className="flex h-11 w-11 items-center justify-center rounded-full border border-white/15 text-stone-200 transition hover:border-amber-300 hover:text-amber-300 focus:outline-none focus-visible:ring-2 focus-visible:ring-amber-300">
<Arrow dir="right" />
</button>
</div>
</div>
</div>
</section>
);
}
Install with one command: npx shadcn@latest add https://we.inc/r/coverflow-gallery-with-3d-perspective-and-autoplay.json
Every print is absolutely placed at the centre of a container that has CSS `perspective`. For each one the component works out how many steps it is from the active print, then animates `x`, `rotateY`, `scale` and `z` to the values for that step. Because the parent has perspective, the rotated prints foreshorten like real objects.
The step distance is wrapped so it always falls between minus three and plus three for seven items. A print that leaves one side fades to zero opacity at step three and reappears on the other side while still invisible, so you never see it cross the stage.
Each card renders a second copy of its frame directly underneath, flipped with `scaleY(-1)`, at 30 percent opacity, with a `mask-image` gradient that fades it out. It is marked `aria-hidden`.
Yes. Set `const playing = false`, or remove the interval effect. The progress fill on the active dot then shows as a solid bar.
Yes. The stage listens for a horizontal pan and moves one print when the drag passes 40 pixels. Vertical scrolling of the page is left alone through `touch-action: pan-y`.
Related: Draggable Card Stack Carousel with Spring Physics, Masonry Gallery with Staggered Reveal and Hover Lift, Product Grid with Filter Chips.
More galleries and carousels or all free UI components. Start for free to have a full site built around it.
We.Inc is an AI-powered website builder you can resell under your own brand. Launch a branded client dashboard, bill on Stripe Connect, and deliver AI-generated websites in minutes. White-label plans start at $99 a month for 25 client sites, with a 7-day free trial and no per-site fees.