Bookshelf

Reading list · 6 books

No dependencies
Code format
React + Tailwind
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>
    </>
  );
}

Free to use in any project, commercial included. MIT-0, no attribution required. Licence

Install with shadcn

npx shadcn@latest add @movin/card-shelf
Source on GitHub

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.

More cards

See all
Snow globe
Papilio machaonSwallowtail, plate IV
Specimen
Zoetrope

Yes. Your plan runs to the end of the period you paid for, then stops.

Accordion