Four square record sleeves lean in a short stack on a bone coloured stage, turned a little so you can see the mouth of the front one. The front sleeve is a real box nine pixels deep with a photograph laminated on its face, a tinted spine and pale board on its edges, and the ones behind it are fanned out so a strip of each cover shows. Shortly after load the black disc slides out of the mouth on a spring, overshoots slightly and settles half way, with the shadow of the cover falling across its grooves. Its paper label then turns once every 1.8 seconds, which is 33 rpm, while the streaks of light on the vinyl hold still. Stepping forward tucks the disc away, swings the front sleeve round to the back of the stack and brings the next cover up. On a phone the whole scene shrinks as one object, so the stack never reflows.
import { useCallback, useEffect, useRef, useState } from "react";
import { motion, useMotionValue, useReducedMotion, useSpring, useTransform } from "framer-motion";
/** The records in the stack, front first. `tone` shows while a cover loads, `label` is the paper label on the disc. */
const records = [
{ title: "Violet Hour", side: "Side A", cat: "OPN 014", src: "https://images.pexels.com/photos/11608677/pexels-photo-11608677.jpeg?auto=compress&cs=tinysrgb&w=800", alt: "Woman with a dark bob looking over her shoulder, her orange shadow thrown on a violet wall", tone: "#5b34c9", label: "#f08a1c", ink: "#2a1466" },
{ title: "Paper Crane", side: "Side A", cat: "OPN 021", src: "https://images.pexels.com/photos/33798260/pexels-photo-33798260.jpeg?auto=compress&cs=tinysrgb&w=800", alt: "Model in a pleated white paper costume and long white gloves kneeling on a red set", tone: "#b3141f", label: "#f3efe6", ink: "#8f0f18" },
{ title: "Cobalt Mask", side: "Side B", cat: "OPN 009", src: "https://images.pexels.com/photos/2035464/pexels-photo-2035464.jpeg?auto=compress&cs=tinysrgb&w=800", alt: "Woman with white line face paint, hoop earrings and a red collar against deep blue", tone: "#0d1670", label: "#e2382b", ink: "#fbe9d2" },
{ title: "Teal Curtain", side: "Side A", cat: "OPN 030", src: "https://images.pexels.com/photos/13430269/pexels-photo-13430269.jpeg?auto=compress&cs=tinysrgb&w=800", alt: "Woman with long braids in a red blouse in front of a teal curtain", tone: "#12a3a8", label: "#0e8f95", ink: "#fff4e0" },
];
const N = records.length;
// Design pixels; the whole scene is scaled down to fit narrow screens.
const S = 360; // sleeve edge
const T = 9; // sleeve thickness
const D = 340; // disc diameter
const OUT = 214; // how far the disc travels out of the sleeve
const SCENE_W = 700;
const SCENE_H = 560;
const HOLD_MS = 4200;
// each sleeve further back steps left, up and away, and leans a little more
const FAN_X = 40;
const FAN_Y = 19;
const FAN_Z = 16;
const FAN_R = 3.2;
const mod = (a: number, n: number) => ((a % n) + n) % n;
const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v));
const card = "#d9d2c3"; // bare board on the edges of a sleeve
function Disc({ rec, spinning }: { rec: (typeof records)[number]; spinning: boolean }) {
return (
<div className="absolute inset-0 rounded-full" style={{ background: "#070708", boxShadow: "0 0 0 1px rgba(255,255,255,0.16) inset, 0 0 0 3px #0c0c0d inset, 0 0 0 4px rgba(255,255,255,0.09) inset" }}>
{/* grooves */}
<div className="absolute inset-[6px] rounded-full" style={{ background: "repeating-radial-gradient(circle at 50% 50%, rgba(255,255,255,0.05) 0px, rgba(255,255,255,0.05) 1px, rgba(0,0,0,0) 1px, rgba(0,0,0,0) 3px)" }} />
{/* the smooth gaps between tracks */}
<div className="absolute inset-0 rounded-full" style={{ background: "radial-gradient(circle at 50% 50%, rgba(0,0,0,0) 0 38.4%, rgba(0,0,0,0.9) 38.6% 39.3%, rgba(0,0,0,0) 39.5% 51.8%, rgba(0,0,0,0.85) 52% 52.5%, rgba(0,0,0,0) 52.7% 61%, rgba(0,0,0,0.85) 61.2% 61.7%, rgba(0,0,0,0) 61.9% 68.6%, rgba(8,8,9,0.95) 68.8% 70.7%)" }} />
{/* light raking across the grooves: stays put while the label turns */}
<div className="absolute inset-[5px] rounded-full" style={{ background: "conic-gradient(from 18deg at 50% 50%, rgba(255,255,255,0) 0deg, rgba(255,255,255,0.38) 20deg, rgba(255,236,214,0.12) 31deg, rgba(255,255,255,0) 52deg, rgba(255,255,255,0) 170deg, rgba(210,225,255,0.10) 190deg, rgba(255,255,255,0.24) 203deg, rgba(255,255,255,0) 232deg, rgba(255,255,255,0) 360deg)" }} />
<div className="absolute inset-[5px] rounded-full" style={{ background: "conic-gradient(from 96deg at 50% 50%, rgba(255,255,255,0) 0deg, rgba(255,255,255,0.07) 14deg, rgba(255,255,255,0) 30deg, rgba(255,255,255,0) 180deg, rgba(255,255,255,0.06) 196deg, rgba(255,255,255,0) 214deg)" }} />
{/* run-out area and paper label */}
<div className="absolute rounded-full" style={{ inset: "31.5%", background: "radial-gradient(circle, #0b0b0c 0 96%, rgba(255,255,255,0.14) 98%, #0b0b0c 100%)" }} />
<div
className={`vinyl-sleeve-gallery-label absolute rounded-full ${spinning ? "vinyl-sleeve-gallery-spin" : ""}`}
style={{ inset: "33.5%", background: rec.label, color: rec.ink, boxShadow: "inset 0 0 0 1px rgba(0,0,0,0.25), inset 0 2px 3px rgba(255,255,255,0.35), inset 0 -3px 6px rgba(0,0,0,0.22)" }}
>
<div className="absolute inset-[7%] rounded-full" style={{ border: "1px solid currentColor", opacity: 0.55 }} />
<div className="absolute inset-x-0 top-[17%] text-center text-[9px] font-black uppercase leading-none tracking-[0.14em]">{rec.title}</div>
<div className="absolute inset-x-0 bottom-[19%] text-center font-mono text-[6px] uppercase leading-none tracking-[0.2em]">
{rec.side} / 33 rpm
</div>
<div className="absolute left-[12%] top-1/2 -translate-y-1/2 font-mono text-[6px] uppercase leading-none tracking-[0.12em]">{rec.cat}</div>
<div className="absolute right-[13%] top-1/2 -translate-y-1/2 font-mono text-[6px] uppercase leading-none tracking-[0.12em]">Stereo</div>
{/* glossy paper */}
<div className="absolute inset-0 rounded-full" style={{ background: "linear-gradient(135deg, rgba(255,255,255,0.22), rgba(255,255,255,0) 45%, rgba(0,0,0,0.16))" }} />
{/* spindle hole */}
<div className="absolute left-1/2 top-1/2 h-[9px] w-[9px] -translate-x-1/2 -translate-y-1/2 rounded-full" style={{ background: "#d8d3c8", boxShadow: "inset 0 1px 2px rgba(0,0,0,0.75), 0 0 0 1px rgba(0,0,0,0.3)" }} />
</div>
</div>
);
}
function Sleeve({ rec, index, slot, prevSlot, open, reduce, onPick }: { rec: (typeof records)[number]; index: number; slot: number; prevSlot: number; open: boolean; reduce: boolean; onPick: () => void }) {
const front = slot === 0;
const out = useSpring(0, { stiffness: 120, damping: 15, mass: 1.1 });
const back = useTransform(out, (v) => -v);
const lipShade = useTransform(out, (v) => clamp(v / 60, 0, 1));
useEffect(() => {
const v = front && open ? OUT : 0;
if (reduce) out.jump(v);
else out.set(v);
}, [front, open, reduce, out]);
// where a sleeve rests for its place in the stack
const rest = { x: -slot * FAN_X, y: -slot * FAN_Y, z: -slot * FAN_Z, rotateZ: -slot * FAN_R };
const wrapped = (prevSlot === 0 && slot === N - 1) || (prevSlot === N - 1 && slot === 0);
const animate = wrapped && !reduce
? { x: [-prevSlot * FAN_X, -S * 0.98, rest.x], y: [-prevSlot * FAN_Y, 30, rest.y], z: [-prevSlot * FAN_Z, -N * 12, rest.z], rotateZ: [-prevSlot * FAN_R, -13, rest.rotateZ] }
: rest;
const transition = reduce ? { duration: 0 } : wrapped ? { duration: 0.95, times: [0, 0.46, 1], ease: [0.45, 0, 0.2, 1] as [number, number, number, number] } : { type: "spring" as const, stiffness: 150, damping: 20, delay: 0.12 };
return (
<motion.div className="absolute" style={{ left: -S / 2, top: -S / 2, width: S, height: S, transformStyle: "preserve-3d" }} initial={false} animate={animate} transition={transition}>
{/* back board */}
<div className="absolute inset-0" style={{ transform: `translateZ(${-T / 2}px)`, background: "linear-gradient(135deg, #cfc7b6, #a59c8a)" }} />
{/* the disc, riding inside the sleeve */}
<motion.div aria-hidden="true" className="absolute overflow-hidden rounded-full" style={{ left: (S - D) / 2, top: (S - D) / 2, width: D, height: D, x: out }}>
<Disc rec={rec} spinning={front && open && !reduce} />
{/* shadow the sleeve mouth drops on the vinyl */}
<motion.div className="absolute inset-y-0" style={{ left: (S + D) / 2 - (S - D), width: 70, x: back, opacity: lipShade, background: "linear-gradient(90deg, rgba(0,0,0,0.78), rgba(0,0,0,0.3) 28%, rgba(0,0,0,0))" }} />
</motion.div>
{/* edges: spine, top, bottom and the two lips of the mouth (only the front sleeve needs them) */}
{front && (
<>
<div className="absolute" style={{ left: -T / 2, top: 0, width: T, height: S, transform: "rotateY(90deg)", background: `linear-gradient(180deg, ${rec.tone}, ${rec.tone})` }}>
<div className="absolute inset-0" style={{ background: "linear-gradient(90deg, rgba(0,0,0,0.35), rgba(255,255,255,0.18) 50%, rgba(0,0,0,0.3))" }} />
</div>
<div className="absolute" style={{ left: 0, top: -T / 2, width: S, height: T, transform: "rotateX(90deg)", background: `linear-gradient(180deg, #f1ece1, ${card})` }} />
<div className="absolute" style={{ left: 0, top: S - T / 2, width: S, height: T, transform: "rotateX(90deg)", background: "linear-gradient(180deg, #8d8575, #6f6859)" }} />
{[1, -1].map((side) => (
<div key={side} className="absolute" style={{ left: S - 1.5, top: 0, width: 3, height: S, transform: `translateZ(${side * (T / 2 - 1.5)}px) rotateY(90deg)`, background: "linear-gradient(180deg, #f6f2e9, #cfc8b9)" }} />
))}
</>
)}
{/* cover */}
<button
type="button"
onClick={onPick}
tabIndex={front ? 0 : -1}
aria-hidden={front ? undefined : true}
aria-pressed={front ? open : undefined}
aria-label={`${rec.title}: ${open && front ? "slide the record back into its sleeve" : "slide the record out"}`}
className="absolute inset-0 block cursor-pointer overflow-hidden outline-none focus-visible:ring-4 focus-visible:ring-inset focus-visible:ring-white"
style={{ transform: `translateZ(${T / 2}px)`, background: rec.tone }}
>
<img src={rec.src} alt={front ? rec.alt : ""} width={S} height={S} draggable={false} loading={index === 0 ? "eager" : "lazy"} className="absolute inset-0 h-full w-full object-cover" />
<span className="absolute left-[18px] top-[16px] text-left text-[13px] font-black uppercase leading-[1.05] tracking-[0.06em] text-white" style={{ textShadow: "0 1px 6px rgba(0,0,0,0.35)" }}>
{rec.title}
</span>
<span className="absolute right-[18px] top-[17px] font-mono text-[8px] uppercase tracking-[0.2em] text-white/90" style={{ textShadow: "0 1px 6px rgba(0,0,0,0.35)" }}>
{rec.cat}
</span>
{/* ring wear from the disc inside, laminate sheen, worn edges */}
<span aria-hidden="true" className="absolute rounded-full" style={{ left: (S - D) / 2 + 4, top: (S - D) / 2 + 4, width: D - 8, height: D - 8, boxShadow: "0 0 0 1px rgba(255,255,255,0.07), inset 0 0 0 1px rgba(0,0,0,0.07), 0 0 5px 1px rgba(255,255,255,0.05)" }} />
<span aria-hidden="true" className="absolute inset-0" style={{ background: "linear-gradient(112deg, rgba(255,255,255,0.20) 0%, rgba(255,255,255,0.04) 22%, rgba(255,255,255,0) 40%, rgba(255,255,255,0) 62%, rgba(0,0,0,0.14) 100%)" }} />
<span aria-hidden="true" className="absolute inset-0" style={{ boxShadow: "inset 1px 1px 0 rgba(255,255,255,0.45), inset -1px -1px 0 rgba(0,0,0,0.25), inset 0 0 22px rgba(0,0,0,0.12)" }} />
{/* sleeves further back sit in the shade of the ones in front */}
<motion.span aria-hidden="true" className="absolute inset-0 bg-black" initial={false} animate={{ opacity: slot === 0 ? 0 : 0.05 + slot * 0.06 }} transition={{ duration: reduce ? 0 : 0.5 }} />
</button>
</motion.div>
);
}
export default function VinylSleeveGallery() {
const reduce = !!useReducedMotion();
const wrapRef = useRef<HTMLDivElement>(null);
const sceneRef = useRef<HTMLDivElement>(null);
const [active, setActive] = useState(0);
const [prevActive, setPrevActive] = useState(0);
const [open, setOpen] = useState(false);
const busy = useRef(false);
const paused = useRef(false);
const timers = useRef<number[]>([]);
const px = useMotionValue(0);
const py = useMotionValue(0);
const ry = useSpring(useTransform(px, (v) => -19 + v * 9), { stiffness: 70, damping: 14 });
const rx = useSpring(useTransform(py, (v) => 5 - v * 7), { stiffness: 70, damping: 14 });
const shadowOpen = useSpring(0, { stiffness: 120, damping: 18 });
const later = (fn: () => void, ms: number) => {
const id = window.setTimeout(fn, ms);
timers.current.push(id);
};
const go = useCallback(
(dir: number) => {
if (busy.current) return;
busy.current = true;
setOpen(false);
later(
() => {
setActive((a) => {
setPrevActive(a);
return mod(a + dir, N);
});
later(
() => {
setOpen(true);
busy.current = false;
},
reduce ? 0 : 900,
);
},
reduce ? 0 : 430,
);
},
[reduce],
);
useEffect(() => {
shadowOpen.set(open ? 1 : 0);
}, [open, shadowOpen]);
useEffect(() => {
const first = window.setTimeout(() => setOpen(true), reduce ? 0 : 350);
const loop = reduce ? 0 : window.setInterval(() => !paused.current && !document.hidden && go(1), HOLD_MS);
return () => {
window.clearTimeout(first);
if (loop) window.clearInterval(loop);
timers.current.forEach((t) => window.clearTimeout(t));
};
}, [go, reduce]);
useEffect(() => {
const wrap = wrapRef.current;
const scene = sceneRef.current;
if (!wrap || !scene) return;
const measure = () => {
const sc = clamp(wrap.clientWidth / (SCENE_W + 80), 0.45, 1);
wrap.style.height = `${Math.round(SCENE_H * sc + 96)}px`;
scene.style.transform = `translateX(-50%) scale(${sc.toFixed(4)})`;
};
measure();
const ro = new ResizeObserver(measure);
ro.observe(wrap);
return () => ro.disconnect();
}, []);
const onMove = (e: React.PointerEvent) => {
if (reduce || e.pointerType === "touch") return;
const r = e.currentTarget.getBoundingClientRect();
px.set(((e.clientX - r.left) / r.width) * 2 - 1);
py.set(((e.clientY - r.top) / r.height) * 2 - 1);
};
const onLeave = () => {
px.set(0);
py.set(0);
paused.current = false;
};
const onKey = (e: React.KeyboardEvent) => {
if (e.key === "ArrowRight" || e.key === "ArrowDown") {
e.preventDefault();
go(1);
} else if (e.key === "ArrowLeft" || e.key === "ArrowUp") {
e.preventDefault();
go(-1);
}
};
const btn = "grid h-10 w-10 place-items-center rounded-full border border-neutral-900/15 bg-white/50 text-neutral-900 transition hover:bg-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-neutral-900";
return (
<section className="relative overflow-hidden bg-[#e4dfd3]">
<style>{`
@keyframes vinyl-sleeve-gallery-turn { to { transform: rotate(360deg); } }
.vinyl-sleeve-gallery-spin { animation: vinyl-sleeve-gallery-turn 1.8s linear infinite; }
@keyframes vinyl-sleeve-gallery-float { 0%, 100% { transform: translateY(0) rotateY(-1.6deg); } 50% { transform: translateY(-7px) rotateY(1.6deg); } }
.vinyl-sleeve-gallery-idle { animation: vinyl-sleeve-gallery-float 7s ease-in-out infinite; }
@media (prefers-reduced-motion: reduce) { .vinyl-sleeve-gallery-spin, .vinyl-sleeve-gallery-idle { animation: none; } }
`}</style>
<div aria-hidden="true" className="pointer-events-none absolute inset-0" style={{ background: "radial-gradient(70% 64% at 46% 38%, #f7f4ec 0%, #ebe6db 46%, #d6d0c2 100%)" }} />
<div
ref={wrapRef}
role="group"
aria-roledescription="carousel"
aria-label="Stack of record sleeves. Use the left and right arrow keys to bring the next record forward."
onKeyDown={onKey}
onPointerMove={onMove}
onPointerEnter={() => (paused.current = true)}
onPointerLeave={onLeave}
onFocus={() => (paused.current = true)}
onBlur={() => (paused.current = false)}
className="relative mx-auto w-full overflow-hidden"
style={{ height: SCENE_H + 96 }}
>
<div ref={sceneRef} className="absolute left-1/2 top-0 select-none" style={{ width: SCENE_W, height: SCENE_H, transformOrigin: "50% 0", transform: "translateX(-50%)", perspective: "2100px", perspectiveOrigin: "50% 42%" }}>
<div className="vinyl-sleeve-gallery-idle absolute" style={{ left: SCENE_W / 2 - 52, top: SCENE_H / 2 + 22, width: 0, height: 0, transformStyle: "preserve-3d" }}>
{/* soft shadows on the floor under the stack and under the disc */}
<div aria-hidden="true" className="absolute" style={{ left: -S / 2 - 130, top: S / 2 + 22, width: S + 200, height: 84, transform: "translateZ(-120px)", background: "radial-gradient(50% 50% at 50% 50%, rgba(40,32,20,0.42) 0%, rgba(40,32,20,0.2) 42%, rgba(40,32,20,0) 100%)" }} />
<motion.div aria-hidden="true" className="absolute" style={{ left: S / 2 - 150, top: S / 2 + 32, width: 420, height: 60, opacity: shadowOpen, z: -120, background: "radial-gradient(50% 50% at 50% 50%, rgba(40,32,20,0.3) 0%, rgba(40,32,20,0.12) 45%, rgba(40,32,20,0) 100%)" }} />
<motion.div className="absolute left-0 top-0" style={{ width: 0, height: 0, transformStyle: "preserve-3d", rotateX: rx, rotateY: ry }}>
{records.map((rec, i) => (
<Sleeve key={rec.cat} rec={rec} index={i} slot={mod(i - active, N)} prevSlot={mod(i - prevActive, N)} open={open} reduce={reduce} onPick={() => (mod(i - active, N) === 0 ? setOpen((o) => !o) : undefined)} />
))}
</motion.div>
</div>
</div>
<p aria-live="polite" className="absolute bottom-5 left-5 font-mono text-[11px] uppercase leading-relaxed tracking-[0.18em] text-neutral-600 sm:bottom-8 sm:left-8">
<span className="tabular-nums text-neutral-950">{String(active + 1).padStart(2, "0")}</span>
<span className="mx-2 text-neutral-500">/</span>
<span className="tabular-nums">{String(N).padStart(2, "0")}</span>
<span className="block text-neutral-950">{records[active].title}</span>
</p>
<div className="absolute bottom-4 right-4 flex gap-2 sm:bottom-7 sm:right-8">
<button type="button" onClick={() => go(-1)} aria-label="Bring the previous record to the front" className={btn}>
<svg aria-hidden="true" width="18" height="18" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 4l-6 6 6 6" />
</svg>
</button>
<button type="button" onClick={() => go(1)} aria-label="Bring the next record to the front" className={btn}>
<svg aria-hidden="true" width="18" height="18" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
<path d="M8 4l6 6-6 6" />
</svg>
</button>
</div>
</div>
</section>
);
}
Install with one command: npx shadcn@latest add https://we.inc/r/vinyl-record-sleeve-gallery-where-the-disc-slides-out-and-spins-beside-its-cover.json
It is five stacked gradients on a black circle. A repeating radial gradient makes the fine grooves, a second radial gradient paints the smooth rings between tracks, and two conic gradients add the pairs of bright wedges you see on a real pressing. The paper label and spindle hole sit on top.
A record is symmetrical around its centre, so when it spins the reflections stay where the lamp put them and only printed things move. Rotating the whole disc would drag the highlights round with it and make it look like a sticker. The label has text at four points so the turning is easy to read.
Nothing is masked. The front sleeve is built from separate faces in a `preserve-3d` box, with the cover nine pixels in front of the back board and the disc travelling on a plane between them. The browser sorts them by depth, so the cover hides the disc exactly as card would.
Every sleeve has a slot number, and its resting offset comes from that slot multiplied by the four `FAN` constants. The one sleeve whose slot wraps round is given a three point keyframe path that first carries it out to the left and down, then in behind the others. The rest move up one place on a spring.
Yes. Remove the `setInterval` line, or raise `HOLD_MS`. The timer already waits while the pointer is over the stage, while anything inside has keyboard focus and while the browser tab is hidden.
Related: Photo Proof Roll Carousel That Feeds over a Roller and Curls onto the Table, Concertina Fold Photo Book Carousel That Opens Flat at the Centre.
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.