Bookshelf
Reading list · 6 books
import { useState, type CSSProperties } from 'react';
const BOOKS = [
{ title: 'Slow In, Slow Out', by: 'Mira Holt', pages: '312 pp', w: 24, h: 150, tone: 'a', font: 'serif', done: 72 },
{ title: 'The Bouncing Ball', by: 'Ana Kerr', pages: '96 pp', w: 16, h: 128, tone: 'b', font: 'sans', done: 100 },
{ title: 'Arcs', by: 'Jun Sato', pages: '204 pp', w: 30, h: 136, tone: 'c', font: 'display', done: 18 },
{ title: 'On Timing', by: 'Dana Kole', pages: '448 pp', w: 20, h: 158, tone: 'a', font: 'mono', done: 0 },
{ title: 'Squash & Stretch', by: 'Priya Rao', pages: '180 pp', w: 26, h: 120, tone: 'b', font: 'serif', done: 44 },
{ title: 'Follow Through', by: 'Theo Marsh', pages: '260 pp', w: 18, h: 144, tone: 'c', font: 'sans', done: 90 },
];
const COVER = 96;
const css = `
.card-shelf { flex: none; --pale: light-dark(#ebedf0, #e5e6e9); --pale-ink: light-dark(#373a40, #1a1c1f); --deep: light-dark(#373a40, #232529); --deep-ink: light-dark(#fbfcfd, #f1f2f3); --mid: light-dark(#5f626a, #393c41); position: relative; width: 262px; height: 214px; }
.card-shelf__row { position: absolute; left: 0; right: 0; bottom: 28px; display: flex; align-items: flex-end; justify-content: center; gap: 2px; perspective: 720px; perspective-origin: 50% 40%; }
.card-shelf__book { --a: var(--w); position: relative; flex: none; width: var(--w); height: var(--h); padding: 0; border: 0; background: none; color: inherit; font: inherit; cursor: pointer; transform-style: preserve-3d; transition: width 0.55s cubic-bezier(0.3, 1.1, 0.5, 1), transform 0.28s cubic-bezier(0.3, 1.5, 0.5, 1); }
.card-shelf__book:hover, .card-shelf__book:focus-visible { transform: translateY(-9px); }
.card-shelf__book:focus-visible { outline: none; }
.card-shelf__book:focus-visible .card-shelf__spine { box-shadow: 0 0 0 2px light-dark(#373a40, #f1f2f3); }
.card-shelf__book[aria-expanded='true'] { --a: calc(var(--w) * 0.44); width: calc(var(--w) * 0.44 + ${COVER}px * 0.9); transform: translateY(-3px); }
.card-shelf__spine, .card-shelf__cover { position: absolute; top: 0; bottom: 0; box-sizing: border-box; transition: transform 0.55s cubic-bezier(0.3, 1.1, 0.5, 1), left 0.55s cubic-bezier(0.3, 1.1, 0.5, 1); }
.card-shelf__spine { left: 0; width: var(--w); display: flex; align-items: center; justify-content: space-between; flex-direction: column; padding: 8px 0; border-radius: 2px 1px 1px 2px; background: var(--bg); color: var(--fg); transform-origin: 100% 50%; box-shadow: inset 2px 0 2px light-dark(rgba(0, 0, 0, 0.06), rgba(0, 0, 0, 0.2)), inset -1px 0 1px light-dark(rgba(0, 0, 0, 0.04), rgba(0, 0, 0, 0.14)); }
.card-shelf__book[aria-expanded='true'] .card-shelf__spine { left: calc(var(--a) - var(--w)); transform: rotateY(-64deg); }
.card-shelf__book--a { --bg: var(--pale); --fg: var(--pale-ink); }
.card-shelf__book--b { --bg: var(--deep); --fg: var(--deep-ink); }
.card-shelf__book--c { --bg: var(--mid); --fg: var(--deep-ink); }
.card-shelf__book--a .card-shelf__spine { box-shadow: inset 2px 0 2px light-dark(rgba(0, 0, 0, 0.04), rgba(0, 0, 0, 0.14)); }
.card-shelf__title { writing-mode: vertical-rl; font-size: 11px; line-height: 1; white-space: nowrap; }
.card-shelf__font--serif { font-family: Georgia, 'Times New Roman', serif; font-style: italic; }
.card-shelf__font--sans { font-family: system-ui, sans-serif; font-weight: 700; letter-spacing: 0.04em; text-transform: uppercase; font-size: 9px; }
.card-shelf__font--display { font-family: Impact, 'Arial Black', sans-serif; font-size: 17px; letter-spacing: 0.04em; }
.card-shelf__font--mono { font-family: 'Courier New', Courier, monospace; font-weight: 700; font-size: 10.5px; }
.card-shelf__band { display: block; width: 100%; height: 3px; flex: none; background: var(--fg); opacity: 0.55; }
.card-shelf__cover { left: var(--a); width: ${COVER}px; padding: 10px 9px; display: flex; flex-direction: column; overflow: hidden; background: var(--bg); color: var(--fg); text-align: left; transform-origin: 0 50%; transform: rotateY(90deg); box-shadow: inset 4px 0 5px -3px light-dark(rgba(0, 0, 0, 0.12), rgba(0, 0, 0, 0.4)), 6px 4px 12px -5px light-dark(rgba(0, 0, 0, 0.14), rgba(0, 0, 0, 0.45)); }
.card-shelf__book[aria-expanded='true'] .card-shelf__cover { transform: rotateY(26deg); }
.card-shelf__cover b { display: block; font-size: 15px; line-height: 1.04; font-weight: inherit; }
.card-shelf__by { display: block; margin-top: 5px; font: 500 10px/1.2 system-ui, sans-serif; letter-spacing: 0; text-transform: none; font-style: normal; opacity: 0.86; }
.card-shelf__meter { display: block; height: 3px; margin-top: auto; border-radius: 2px; background: color-mix(in oklab, var(--fg) 26%, transparent); overflow: hidden; }
.card-shelf__meter i { display: block; height: 100%; width: var(--done); background: var(--fg); }
.card-shelf__pages { display: block; margin-top: 4px; font: 500 9px/1.2 system-ui, sans-serif; letter-spacing: 0; text-transform: none; font-style: normal; opacity: 0.86; }
.card-shelf__plank { position: absolute; left: 0; right: 0; bottom: 20px; height: 8px; border-radius: 2px; background: light-dark(#6d7179, #7d8087); box-shadow: 0 7px 10px -3px light-dark(rgba(0, 0, 0, 0.14), rgba(0, 0, 0, 0.45)); }
.card-shelf__bracket { position: absolute; bottom: 6px; width: 8px; height: 14px; background: light-dark(#cbced3, #393c41); clip-path: polygon(0 0, 100% 0, 100% 34%, 0 100%); }
.card-shelf__note { position: absolute; left: 0; right: 0; bottom: 0; margin: 0; color: light-dark(#5f626a, #a7abb2); font-size: 11px; text-align: center; }
@media (prefers-reduced-motion: reduce) {
.card-shelf__book, .card-shelf__spine, .card-shelf__cover { transition: none; }
}
`;
export function ShelfCard() {
const [open, setOpen] = useState(0);
return (
<>
<style>
{css}
</style>
<section className="card-shelf" aria-label="Reading list">
<div className="card-shelf__row">
{BOOKS.map((book, i) => {
const on = open === i;
const fontClass = `card-shelf__font--${book.font}`;
return (
<button
key={book.title}
type="button"
className={`card-shelf__book card-shelf__book--${book.tone}`}
style={{ '--w': `${book.w}px`, '--h': `${book.h}px`, '--done': `${book.done}%` } as CSSProperties}
aria-expanded={on}
aria-label={`${book.title} by ${book.by}, ${book.done}% read`}
onClick={() => setOpen(on ? -1 : i)}
>
<span className="card-shelf__spine" aria-hidden="true">
<span className="card-shelf__band" />
<span className={`card-shelf__title ${fontClass}`}>{book.title}</span>
<span className="card-shelf__band" />
</span>
<span className={`card-shelf__cover ${fontClass}`} aria-hidden="true">
<b>{book.title}</b>
<span className="card-shelf__by">{book.by}</span>
<span className="card-shelf__meter">
<i />
</span>
<span className="card-shelf__pages">
{book.pages} · {book.done}%
</span>
</span>
</button>
);
})}
</div>
<span className="card-shelf__plank" aria-hidden="true" />
<span className="card-shelf__bracket" style={{ left: 26 }} aria-hidden="true" />
<span className="card-shelf__bracket" style={{ right: 26, transform: 'scaleX(-1)' }} aria-hidden="true" />
<p className="card-shelf__note">Reading list · 6 books</p>
</section>
</>
);
}Markup where the component goes, CSS in your stylesheet, script before </body>. In Vue, Svelte or Angular, call initCardShelf(el) on the mounted element instead.
<section class="card-shelf" aria-label="Reading list">
<div class="card-shelf__row">
<button type="button" class="card-shelf__book card-shelf__book--a" style="--w:24px;--h:150px;--done:72%" aria-expanded="true" aria-label="Slow In, Slow Out by Mira Holt, 72% read">
<span class="card-shelf__spine" aria-hidden="true">
<span class="card-shelf__band"></span>
<span class="card-shelf__title card-shelf__font--serif">Slow In, Slow Out</span>
<span class="card-shelf__band"></span>
</span>
<span class="card-shelf__cover card-shelf__font--serif" aria-hidden="true">
<b>Slow In, Slow Out</b>
<span class="card-shelf__by">Mira Holt</span>
<span class="card-shelf__meter">
<i></i>
</span>
<span class="card-shelf__pages">312 pp · 72%</span>
</span>
</button>
<button type="button" class="card-shelf__book card-shelf__book--b" style="--w:16px;--h:128px;--done:100%" aria-expanded="false" aria-label="The Bouncing Ball by Ana Kerr, 100% read">
<span class="card-shelf__spine" aria-hidden="true">
<span class="card-shelf__band"></span>
<span class="card-shelf__title card-shelf__font--sans">The Bouncing Ball</span>
<span class="card-shelf__band"></span>
</span>
<span class="card-shelf__cover card-shelf__font--sans" aria-hidden="true">
<b>The Bouncing Ball</b>
<span class="card-shelf__by">Ana Kerr</span>
<span class="card-shelf__meter">
<i></i>
</span>
<span class="card-shelf__pages">96 pp · 100%</span>
</span>
</button>
<button type="button" class="card-shelf__book card-shelf__book--c" style="--w:30px;--h:136px;--done:18%" aria-expanded="false" aria-label="Arcs by Jun Sato, 18% read">
<span class="card-shelf__spine" aria-hidden="true">
<span class="card-shelf__band"></span>
<span class="card-shelf__title card-shelf__font--display">Arcs</span>
<span class="card-shelf__band"></span>
</span>
<span class="card-shelf__cover card-shelf__font--display" aria-hidden="true">
<b>Arcs</b>
<span class="card-shelf__by">Jun Sato</span>
<span class="card-shelf__meter">
<i></i>
</span>
<span class="card-shelf__pages">204 pp · 18%</span>
</span>
</button>
<button type="button" class="card-shelf__book card-shelf__book--a" style="--w:20px;--h:158px;--done:0%" aria-expanded="false" aria-label="On Timing by Dana Kole, 0% read">
<span class="card-shelf__spine" aria-hidden="true">
<span class="card-shelf__band"></span>
<span class="card-shelf__title card-shelf__font--mono">On Timing</span>
<span class="card-shelf__band"></span>
</span>
<span class="card-shelf__cover card-shelf__font--mono" aria-hidden="true">
<b>On Timing</b>
<span class="card-shelf__by">Dana Kole</span>
<span class="card-shelf__meter">
<i></i>
</span>
<span class="card-shelf__pages">448 pp · 0%</span>
</span>
</button>
<button type="button" class="card-shelf__book card-shelf__book--b" style="--w:26px;--h:120px;--done:44%" aria-expanded="false" aria-label="Squash & Stretch by Priya Rao, 44% read">
<span class="card-shelf__spine" aria-hidden="true">
<span class="card-shelf__band"></span>
<span class="card-shelf__title card-shelf__font--serif">Squash & Stretch</span>
<span class="card-shelf__band"></span>
</span>
<span class="card-shelf__cover card-shelf__font--serif" aria-hidden="true">
<b>Squash & Stretch</b>
<span class="card-shelf__by">Priya Rao</span>
<span class="card-shelf__meter">
<i></i>
</span>
<span class="card-shelf__pages">180 pp · 44%</span>
</span>
</button>
<button type="button" class="card-shelf__book card-shelf__book--c" style="--w:18px;--h:144px;--done:90%" aria-expanded="false" aria-label="Follow Through by Theo Marsh, 90% read">
<span class="card-shelf__spine" aria-hidden="true">
<span class="card-shelf__band"></span>
<span class="card-shelf__title card-shelf__font--sans">Follow Through</span>
<span class="card-shelf__band"></span>
</span>
<span class="card-shelf__cover card-shelf__font--sans" aria-hidden="true">
<b>Follow Through</b>
<span class="card-shelf__by">Theo Marsh</span>
<span class="card-shelf__meter">
<i></i>
</span>
<span class="card-shelf__pages">260 pp · 90%</span>
</span>
</button>
</div>
<span class="card-shelf__plank" aria-hidden="true"></span>
<span class="card-shelf__bracket" style="left:26px" aria-hidden="true"></span>
<span class="card-shelf__bracket" style="right:26px;transform:scaleX(-1)" aria-hidden="true"></span>
<p class="card-shelf__note">Reading list · 6 books</p>
</section>.card-shelf { flex: none; --pale: light-dark(#ebedf0, #e5e6e9); --pale-ink: light-dark(#373a40, #1a1c1f); --deep: light-dark(#373a40, #232529); --deep-ink: light-dark(#fbfcfd, #f1f2f3); --mid: light-dark(#5f626a, #393c41); position: relative; width: 262px; height: 214px; }
.card-shelf__row { position: absolute; left: 0; right: 0; bottom: 28px; display: flex; align-items: flex-end; justify-content: center; gap: 2px; perspective: 720px; perspective-origin: 50% 40%; }
.card-shelf__book { --a: var(--w); position: relative; flex: none; width: var(--w); height: var(--h); padding: 0; border: 0; background: none; color: inherit; font: inherit; cursor: pointer; transform-style: preserve-3d; transition: width 0.55s cubic-bezier(0.3, 1.1, 0.5, 1), transform 0.28s cubic-bezier(0.3, 1.5, 0.5, 1); }
.card-shelf__book:hover, .card-shelf__book:focus-visible { transform: translateY(-9px); }
.card-shelf__book:focus-visible { outline: none; }
.card-shelf__book:focus-visible .card-shelf__spine { box-shadow: 0 0 0 2px light-dark(#373a40, #f1f2f3); }
.card-shelf__book[aria-expanded='true'] { --a: calc(var(--w) * 0.44); width: calc(var(--w) * 0.44 + 96px * 0.9); transform: translateY(-3px); }
.card-shelf__spine, .card-shelf__cover { position: absolute; top: 0; bottom: 0; box-sizing: border-box; transition: transform 0.55s cubic-bezier(0.3, 1.1, 0.5, 1), left 0.55s cubic-bezier(0.3, 1.1, 0.5, 1); }
.card-shelf__spine { left: 0; width: var(--w); display: flex; align-items: center; justify-content: space-between; flex-direction: column; padding: 8px 0; border-radius: 2px 1px 1px 2px; background: var(--bg); color: var(--fg); transform-origin: 100% 50%; box-shadow: inset 2px 0 2px light-dark(rgba(0, 0, 0, 0.06), rgba(0, 0, 0, 0.2)), inset -1px 0 1px light-dark(rgba(0, 0, 0, 0.04), rgba(0, 0, 0, 0.14)); }
.card-shelf__book[aria-expanded='true'] .card-shelf__spine { left: calc(var(--a) - var(--w)); transform: rotateY(-64deg); }
.card-shelf__book--a { --bg: var(--pale); --fg: var(--pale-ink); }
.card-shelf__book--b { --bg: var(--deep); --fg: var(--deep-ink); }
.card-shelf__book--c { --bg: var(--mid); --fg: var(--deep-ink); }
.card-shelf__book--a .card-shelf__spine { box-shadow: inset 2px 0 2px light-dark(rgba(0, 0, 0, 0.04), rgba(0, 0, 0, 0.14)); }
.card-shelf__title { writing-mode: vertical-rl; font-size: 11px; line-height: 1; white-space: nowrap; }
.card-shelf__font--serif { font-family: Georgia, 'Times New Roman', serif; font-style: italic; }
.card-shelf__font--sans { font-family: system-ui, sans-serif; font-weight: 700; letter-spacing: 0.04em; text-transform: uppercase; font-size: 9px; }
.card-shelf__font--display { font-family: Impact, 'Arial Black', sans-serif; font-size: 17px; letter-spacing: 0.04em; }
.card-shelf__font--mono { font-family: 'Courier New', Courier, monospace; font-weight: 700; font-size: 10.5px; }
.card-shelf__band { display: block; width: 100%; height: 3px; flex: none; background: var(--fg); opacity: 0.55; }
.card-shelf__cover { left: var(--a); width: 96px; padding: 10px 9px; display: flex; flex-direction: column; overflow: hidden; background: var(--bg); color: var(--fg); text-align: left; transform-origin: 0 50%; transform: rotateY(90deg); box-shadow: inset 4px 0 5px -3px light-dark(rgba(0, 0, 0, 0.12), rgba(0, 0, 0, 0.4)), 6px 4px 12px -5px light-dark(rgba(0, 0, 0, 0.14), rgba(0, 0, 0, 0.45)); }
.card-shelf__book[aria-expanded='true'] .card-shelf__cover { transform: rotateY(26deg); }
.card-shelf__cover b { display: block; font-size: 15px; line-height: 1.04; font-weight: inherit; }
.card-shelf__by { display: block; margin-top: 5px; font: 500 10px/1.2 system-ui, sans-serif; letter-spacing: 0; text-transform: none; font-style: normal; opacity: 0.86; }
.card-shelf__meter { display: block; height: 3px; margin-top: auto; border-radius: 2px; background: color-mix(in oklab, var(--fg) 26%, transparent); overflow: hidden; }
.card-shelf__meter i { display: block; height: 100%; width: var(--done); background: var(--fg); }
.card-shelf__pages { display: block; margin-top: 4px; font: 500 9px/1.2 system-ui, sans-serif; letter-spacing: 0; text-transform: none; font-style: normal; opacity: 0.86; }
.card-shelf__plank { position: absolute; left: 0; right: 0; bottom: 20px; height: 8px; border-radius: 2px; background: light-dark(#6d7179, #7d8087); box-shadow: 0 7px 10px -3px light-dark(rgba(0, 0, 0, 0.14), rgba(0, 0, 0, 0.45)); }
.card-shelf__bracket { position: absolute; bottom: 6px; width: 8px; height: 14px; background: light-dark(#cbced3, #393c41); clip-path: polygon(0 0, 100% 0, 100% 34%, 0 100%); }
.card-shelf__note { position: absolute; left: 0; right: 0; bottom: 0; margin: 0; color: light-dark(#5f626a, #a7abb2); font-size: 11px; text-align: center; }
@media (prefers-reduced-motion: reduce) {
.card-shelf__book, .card-shelf__spine, .card-shelf__cover { transition: none; }
}function initCardShelf(root) {
var books = root.querySelectorAll('.card-shelf__book');
// One book out at a time; the width change makes the others shuffle along.
function onClick(e) {
var book = e.target.closest('.card-shelf__book');
if (!book) return;
var wasOpen = book.getAttribute('aria-expanded') === 'true';
books.forEach(function (b) {
b.setAttribute('aria-expanded', String(b === book && !wasOpen));
});
}
root.addEventListener('click', onClick);
return function () {
root.removeEventListener('click', onClick);
};
}
document.querySelectorAll('.card-shelf').forEach(initCardShelf);Goal: Create a "Bookshelf" card.
Visual behavior: A reading list as a real shelf of spines, each with its own typeface. Hover and one rises; click and it turns out to show its cover while the others make room.
Technique:
- Each book is a spine and a cover meeting at a corner. Opening turns the spine away one way and the cover away the other, so the corner comes towards you, while the book widens and the flex row shuffles the others along.
- Width, height and how much has been read come from CSS variables on each book, and the spine text uses vertical writing mode.
Accessibility: Each book is a button with aria-expanded and an aria-label giving its title, author and progress. Spines and covers are aria-hidden. Under prefers-reduced-motion the books change at once.
My stack: {{USER_STACK}}
My styling: {{USER_STYLING}}
My constraints: {{USER_CONSTRAINTS}}
My colors: neutral only. Black, white and grays, no hues. Use the color values above as written, or my project's closest neutral tokens.
Return a single self-contained component. No dependencies.Goal: Create a "Bookshelf" card.
Visual behavior: A reading list as a real shelf of spines, each with its own typeface. Hover and one rises; click and it turns out to show its cover while the others make room.
Technique:
- Each book is a spine and a cover meeting at a corner. Opening turns the spine away one way and the cover away the other, so the corner comes towards you, while the book widens and the flex row shuffles the others along.
- Width, height and how much has been read come from CSS variables on each book, and the spine text uses vertical writing mode.
Accessibility: Each book is a button with aria-expanded and an aria-label giving its title, author and progress. Spines and covers are aria-hidden. Under prefers-reduced-motion the books change at once.
My stack: {{USER_STACK}}
My styling: {{USER_STYLING}}
My constraints: {{USER_CONSTRAINTS}}
My colors: use my project's existing color tokens. The color values above are grayscale placeholders. Map each one by its role (surface, text, border, accent, glow) to my theme, and keep their relative lightness so contrast and the effect still read.
Return a single self-contained component. No dependencies.Paste into Claude, ChatGPT or Cursor after filling in {{USER_STACK}}, {{USER_STYLING}} and {{USER_CONSTRAINTS}}. Neutral keeps the greys you see here; Match my project asks the model to map them onto your own colour tokens.
Free to use in any project, commercial included. MIT-0, no attribution required. Licence
About this component
A reading list as a real shelf of spines, each with its own typeface. Hover and one rises; click and it turns out to show its cover while the others make room.
- Dependencies
- None
- Difficulty
- Intermediate
- Triggers
- card, hover, click, 3d
- Techniques
- 3d transform, perspective, preserve 3d, css transition, css variables
- Reduced motion
- Respected
- Theme
- Light and dark (follows your page's color-scheme)
Each book is a button with aria-expanded and an aria-label giving its title, author and progress. Spines and covers are aria-hidden. Under prefers-reduced-motion the books change at once.