Album shelf
- Source
- After bencho.dev ↗
- Author
- Interaction after bencho.dev
- Licence
- Original code — interaction after bencho.dev
Loading…
Hover the shelf to pull an album out; click to keep it facing you.
Code
"use client";
import { motion } from "motion/react";
import { useState } from "react";
import { Surface } from "../ui";
const ALBUMS = [
{ title: "Low Tide", artist: "Mara Vey", bg: "linear-gradient(160deg,#0f2a44,#2f7fa8 60%,#c7e6ef)", spine: "#16334f" },
{ title: "Paper Sun", artist: "Okra", bg: "radial-gradient(circle at 60% 40%,#ffcf5c 0 22%,transparent 23%),linear-gradient(#f26b38,#b8333a)", spine: "#b8333a" },
{ title: "Field Notes", artist: "J. Arden", bg: "repeating-linear-gradient(90deg,#e9e4d6 0 0.5em,#d5cfbd 0.5em 1em)", spine: "#cfc8b4" },
{ title: "Blue Hour", artist: "Lumen", bg: "radial-gradient(circle at 30% 70%,#9fb2ff,transparent 50%),linear-gradient(#2B46B8,#0d1640)", spine: "#1d2f80" },
{ title: "Monolith", artist: "Grey Area", bg: "linear-gradient(135deg,#151515 50%,#3a3a3a 50%)", spine: "#151515" },
{ title: "Moss", artist: "Fennel", bg: "radial-gradient(circle at 50% 120%,#c9e27a,transparent 60%),linear-gradient(#2d4a2b,#5d7d3a)", spine: "#2d4a2b" },
{ title: "Static", artist: "Neon Ghost", bg: "repeating-radial-gradient(circle at 50% 50%,#ff5fa2 0 0.3em,#ffd1e6 0.3em 0.6em)", spine: "#e2508f" },
];
export default function AlbumShelf() {
const [hot, setHot] = useState<number | null>(null);
const [picked, setPicked] = useState<number | null>(null);
const focus = hot ?? picked;
return (
<Surface>
<div className="flex flex-col items-center gap-[1.6em]">
<div className="relative flex h-[7.6em] items-end [perspective:900px]" onPointerLeave={() => setHot(null)}>
{ALBUMS.map((a, i) => {
const on = focus === i;
const side = focus === null ? 0 : i < focus ? -1 : i > focus ? 1 : 0;
return (
<motion.button
key={a.title}
type="button"
onPointerEnter={() => setHot(i)}
onClick={() => setPicked(i === picked ? null : i)}
className="relative -mx-[2em] h-[7em] w-[7em] cursor-pointer rounded-[0.25em] [transform-style:preserve-3d]"
animate={{
rotateY: on ? 0 : (side ? -side : i <= 3 ? 1 : -1) * 62,
x: `${side * 2}em`,
y: on ? "-0.6em" : "0em",
scale: on ? 1.06 : 1,
z: on ? 40 : 0,
}}
style={{ zIndex: on ? 20 : 10 - Math.abs((focus ?? 3) - i), background: a.bg }}
transition={{ type: "spring", stiffness: 260, damping: 24 }}
>
<span className="absolute inset-0 rounded-[0.25em] shadow-[inset_0_0_0_1px_rgba(0,0,0,0.08),0_10px_24px_-12px_rgba(0,0,0,0.5)]" />
<span className="absolute inset-0 rounded-[0.25em] bg-gradient-to-r from-black/25 via-transparent to-white/10" />
</motion.button>
);
})}
</div>
<div className="h-[2.4em] text-center">
{focus !== null ? (
<motion.div key={focus} initial={{ opacity: 0, y: 6 }} animate={{ opacity: 1, y: 0 }}>
<p className="text-[1em] font-medium">{ALBUMS[focus].title}</p>
<p className="text-[0.75em] text-[#8a8984]">{ALBUMS[focus].artist}</p>
</motion.div>
) : (
<p className="pt-[0.6em] font-mono text-[0.7em] text-[#8a8984]">hover the shelf · click to keep one out</p>
)}
</div>
</div>
</Surface>
);
}
Prompt
Build "Album shelf", a card animation. What it does: Hover the shelf to pull an album out; click to keep it facing you. Write it as a single self-contained React component in TypeScript that fills its parent (w-full h-full), styled with Tailwind CSS, using motion. Technique: an HTML canvas, the Motion library, pointer tracking. It sits on a light background, in a 16:10 frame. Respect prefers-reduced-motion with a still state, pause work when the element is off screen, and keep it accessible. Original: https://bencho.dev (Interaction after bencho.dev, Original code — interaction after bencho.dev).