Cards
VinylSleeve
A worn record sleeve with the disc just showing. Hover or focus slides the record out, press sets the arm down and it spins, press again and it slows to a stop.
Download
- React
- Tailwind CSS v4
- motion
components/ui/vinyl-sleeve.tsx
"use client";
import { useId, useRef, useState } from "react";
import { motion, useAnimationFrame, useMotionValue, useReducedMotion } from "motion/react";
type VinylSleeveProps = {
title: string;
artist?: string;
/** Sleeve art: paper, middle arc, front arc. The disc label takes the middle one. */
colors?: [string, string, string];
/** Catalogue number printed on the sleeve. */
catalogue?: string;
/** Text on the round shop sticker. Leave out for none. */
price?: string;
/** Spin speed in turns a minute. */
rpm?: number;
onToggle?: (playing: boolean) => void;
className?: string;
};
/* VinylSleeve: a worn record sleeve with the disc just showing. Hover slides the record out, press sets the arm down and it spins, then slows to a stop. */
export function VinylSleeve({
title,
artist = "",
colors = ["#e9dcc0", "#d6532c", "#22333b"],
catalogue,
price,
rpm = 33,
onToggle,
className = "",
}: VinylSleeveProps) {
const reduce = useReducedMotion();
const [over, setOver] = useState(false);
const [playing, setPlaying] = useState(false);
const spin = useMotionValue(0);
const speed = useRef(0);
const ring = useId();
const out = over || playing;
const [paper, mid, front] = colors;
const slide = reduce ? { duration: 0 } : { type: "spring" as const, stiffness: 120, damping: 17 };
useAnimationFrame((_, dt) => {
if (reduce) return;
const goal = playing ? rpm * 6 : 0;
if (!goal && speed.current < 0.5) return;
speed.current += (goal - speed.current) * Math.min(1, dt * (playing ? 0.004 : 0.0016));
spin.set((spin.get() + (speed.current * dt) / 1000) % 360);
});
function toggle() {
onToggle?.(!playing);
setPlaying(!playing);
}
return (
<div className={`@container w-full max-w-[560px] ${className}`}>
<button
type="button"
aria-pressed={playing}
aria-label={`Play ${title}${artist ? ` by ${artist}` : ""}`}
onClick={toggle}
onPointerEnter={(e) => e.pointerType === "mouse" && setOver(true)}
onPointerLeave={() => setOver(false)}
onFocus={(e) => e.currentTarget.matches(":focus-visible") && setOver(true)}
onBlur={() => setOver(false)}
className="relative block aspect-[1.6/1] w-full select-none text-left text-[1.875cqw] leading-none"
>
<motion.span aria-hidden className="absolute inset-0 block" initial={false} animate={{ x: out ? 0 : "8.6em" }} transition={slide}>
<motion.span className="absolute left-[1.2em] top-[1.2em] block size-[30.9em]" initial={false} animate={{ x: out ? "16.5em" : "5.4em" }} transition={slide}>
<motion.span className="absolute inset-0 block rounded-full bg-[#121212] shadow-[0_0.5em_1.2em_rgba(0,0,0,0.5),inset_0_0_0_0.1em_#3a3a3a]" style={{ rotate: spin }}>
<span className="absolute inset-0 rounded-full" style={{ background: "repeating-radial-gradient(circle, #141414 0 max(0.1em, 1px), #2c2c2c max(0.1em, 1px) max(0.17em, 2px))" }} />
{[0.56, 0.72, 0.86].map((r) => (
<span key={r} className="absolute rounded-full border-[0.14em] border-[#0a0a0a]" style={{ inset: `${(1 - r) * 50}%` }} />
))}
<span className="absolute inset-[31%] rounded-full" style={{ background: mid }}>
<svg viewBox="0 0 100 100" className="h-full w-full">
<path id={ring} d="M50 50m-37 0a37 37 0 1 1 74 0a37 37 0 1 1-74 0" fill="none" />
<text fontSize="8.2" fontWeight="700" letterSpacing="1" fill={paper}>
<textPath href={`#${ring}`}>{`${title} · ${artist}`.slice(0, 44)}</textPath>
</text>
<circle cx="50" cy="50" r="19" fill="none" stroke={paper} strokeWidth="0.7" opacity="0.6" />
<circle cx="50" cy="50" r="3.4" fill="#0d0d0d" />
</svg>
</span>
</motion.span>
<span className="absolute inset-0 rounded-full bg-[conic-gradient(from_20deg,transparent_0_8%,rgba(255,255,255,0.16)_13%,transparent_20%_50%,rgba(255,255,255,0.13)_60%,transparent_68%)] [mask:radial-gradient(circle,transparent_38%,#000_39%)]" />
</motion.span>
<span className="absolute left-0 top-0 block size-[33.3em] overflow-hidden rounded-[0.25em] shadow-[0_0.7em_1.4em_rgba(0,0,0,0.55)]" style={{ background: paper }}>
<span
className="absolute inset-0"
style={{ background: `radial-gradient(circle at 0% 100%, ${front} 0 26%, ${mid} 26% 46%, ${front} 46% 58%, ${mid} 58% 66%, transparent 66%)` }}
/>
<span className="absolute inset-0 bg-[radial-gradient(rgba(70,50,20,0.13)_0.5px,transparent_0.8px)] bg-[length:0.3em_0.3em]" />
<span className="absolute inset-[1.8em] rounded-full border-[0.15em] border-white/20 [mask:conic-gradient(#000_0_32%,transparent_40%_56%,#000_70%)]" />
<span className="absolute inset-0 bg-[linear-gradient(90deg,rgba(0,0,0,0.3),transparent_2.5%,transparent_97%,rgba(0,0,0,0.42))]" />
<span className="absolute inset-0 rounded-[0.25em] shadow-[inset_0_0_1.5em_rgba(90,60,20,0.32)]" />
<span className="absolute right-[1.7em] top-[1.8em] block max-w-[24em] text-right" style={{ color: front }}>
<span className="block font-serif text-[3.5em] font-black leading-[0.98] tracking-tight">{title}</span>
{artist && <span className="mt-[0.8em] block text-[1.45em] font-semibold tracking-wide">{artist}</span>}
</span>
{catalogue && (
<span className="absolute left-[1.5em] top-[1.3em] font-mono text-[1em] font-bold tracking-widest" style={{ color: front }}>
{catalogue}
</span>
)}
{price && (
<span className="absolute bottom-[1.8em] left-[1.7em] grid size-[6em] -rotate-12 place-items-center rounded-full bg-[#ffd23f] font-mono text-[1em] font-black text-[#3a2c14] shadow-[0_0.1em_0.2em_rgba(0,0,0,0.4)]">
{price}
</span>
)}
</span>
<motion.span
className="absolute left-[50.5em] top-[4em] block size-0"
initial={false}
animate={{ opacity: out ? 1 : 0, rotate: playing ? 20 : 0 }}
transition={reduce ? { duration: 0 } : { rotate: { type: "spring", stiffness: 70, damping: 14, delay: playing ? 0.35 : 0 }, opacity: { duration: 0.25 } }}
>
<svg viewBox="-3 -3 12 30" className="absolute left-[-3em] top-[-3em] h-[30em] w-[12em] overflow-visible">
<circle cx="0" cy="0" r="2.7" fill="#3a3a3f" stroke="#8c8c94" strokeWidth="0.4" />
<circle cx="0" cy="0" r="1" fill="#c9c9d1" />
<path d="M0 0 L0 21 L-1.2 23.5" fill="none" stroke="#c9c9d1" strokeWidth="0.6" strokeLinejoin="round" strokeLinecap="round" />
<rect x="-2.3" y="23" width="2.4" height="1.4" rx="0.3" fill="#e9e9ee" transform="rotate(-24 -1.2 23.5)" />
</svg>
</motion.span>
</motion.span>
</button>
</div>
);
}
1. Install the dependency.
npm i motion2. Paste the Code tab into
components/ui/vinyl-sleeve.tsx. The styles are Tailwind CSS v4 classes.3. Use it. This is the preview above:
app/vinyl-sleeve-demo.tsx
import { VinylSleeve } from "@/components/ui/vinyl-sleeve"; export default function VinylSleeveExample() { return ( <div className="grid h-full place-items-center overflow-hidden bg-[#26201b] bg-[radial-gradient(circle_at_50%_0%,#4a3a2c,transparent_70%)] p-4"> <VinylSleeve title="Midnight Filter Coffee" artist="The Mylapore Trio" catalogue="LP-1974" price="₹120" /> </div> ); }
Props
| Name | Type | Default | What it does |
|---|---|---|---|
| title | string | — | Album name, on the sleeve and round the label. |
| artist | string | "" | Artist name. |
| colors | [string, string, string] | cream, rust, slate | Sleeve paper, middle arc, front arc. The disc label takes the middle one. |
| catalogue | string | — | Catalogue number printed on the sleeve. |
| price | string | — | Text on the round shop sticker. Leave out for none. |
| rpm | number | 33 | Spin speed in turns a minute. |
| onToggle | (playing: boolean) => void | — | Runs on each press. |
| className | string | "" | Size. Everything inside scales with the width. |