# Book shelf

> 36 Japanese manga volumes on a reading shelf: covers lift, tilt after the pointer and hinge open as 3D books.

A manga reading list with 36 Japanese editions: nine volumes each of Naruto, Hajime no Ippo, Bleach and Bakuman. Their original covers sit on a continuous library surface with paper edges, compact controls and vertical reading groups. Hover a volume to lift and tilt it; open it to lift the book out of the shelf and hinge its cover left, revealing its illustrated inside cover and reading details on the facing page. Search, filter, sort, switch themes or navigate with the keyboard.

A React component by Bruno Delorence: https://brunodelorence.com/components/book-shelf. MIT License, Copyright (c) 2026 Bruno Delorence.

Category: Motion.

## Anatomy

1. **Top bar**: The app’s name, the filter and the tools. Below 560 px it takes two rows.
2. **Filter**: A native `select` laid invisibly over the pill: every group or one, with the count of volumes shown.
3. **Theme and depth**: The eye picks the app’s light or dark theme; the cube switches between 3D volumes and flat covers.
4. **Search and sort**: Search opens a field that matches title, author, Japanese title and genre; `A→Z` and `Year` sort within the groups.
5. **Group tab**: A vertical tab per reading state with its count. Pressing it folds the group shut.
6. **Volume**: A button drawn as a paperback: the cover, and the page edges painted beside it. Arrow keys move between volumes, Enter opens one.
7. **Lifted volume**: Under the pointer it turns solid 3D, lifts and tilts towards the pointer, and shows its genre, chapter and progress.
8. **Rating**: A yellow badge that stays on the cover when the volume has a rating.

## When to use

- A personal collection where the cover is how people recognise an item: books, manga, records, games.
- Items with a reading or progress state worth grouping by: `Reading`, `Plan to read`, `Completed`.
- A library people browse rather than search: the tilt and the lift reward looking around the covers.
- Opening one item into a focused detail view without leaving the shelf: the book opens over it and closes back into its slot.
- A showcase or a landing moment for a reading app, where the 3D volumes carry the brand.

## When not to use

- Large catalogues of hundreds of items. Use a virtualised list or a table with search first.
- Items without cover art: placeholder covers make the 3D shelf look empty. Use a list with text.
- When the details must be compared side by side (dates, prices, ratings). Use a table.
- Low-end devices or many shelves on one page: each lifted volume is a 3D layer. Use `defaultState={{ flat: true }}` or a flat grid.
- Dense admin views where the playful lift and the cover flight get in the way of quick edits.

## Installation

Install it with the shadcn CLI, into components/book-shelf of any React project:

```sh
npx shadcn@latest add https://brunodelorence.com/r/book-shelf.json
```

Or copy all the source files below into one folder of your project. Either way it needs React 19.2 and a bundler with CSS Modules (Next.js, Vite).

It has a dark colour scheme. Its colours are CSS light-dark() pairs, kept inside @supports so that builds which rewrite light-dark() for older browsers leave them alone: it follows your page’s color-scheme (light unless the page sets one) or its colorScheme prop, with nothing to configure. Browsers without light-dark() show it light.

Copy these media files to the same paths under your public folder:

- https://brunodelorence.com/experiments/book-shelf/naruto-1.webp
- https://brunodelorence.com/experiments/book-shelf/naruto-2.webp
- https://brunodelorence.com/experiments/book-shelf/naruto-3.webp
- https://brunodelorence.com/experiments/book-shelf/naruto-4.webp
- https://brunodelorence.com/experiments/book-shelf/naruto-5.webp
- https://brunodelorence.com/experiments/book-shelf/naruto-6.webp
- https://brunodelorence.com/experiments/book-shelf/naruto-7.webp
- https://brunodelorence.com/experiments/book-shelf/naruto-8.webp
- https://brunodelorence.com/experiments/book-shelf/naruto-9.webp
- https://brunodelorence.com/experiments/book-shelf/ippo-1.webp
- https://brunodelorence.com/experiments/book-shelf/ippo-2.webp
- https://brunodelorence.com/experiments/book-shelf/ippo-3.webp
- https://brunodelorence.com/experiments/book-shelf/ippo-4.webp
- https://brunodelorence.com/experiments/book-shelf/ippo-5.webp
- https://brunodelorence.com/experiments/book-shelf/ippo-6.webp
- https://brunodelorence.com/experiments/book-shelf/ippo-7.webp
- https://brunodelorence.com/experiments/book-shelf/ippo-8.webp
- https://brunodelorence.com/experiments/book-shelf/ippo-9.webp
- https://brunodelorence.com/experiments/book-shelf/bleach-1.webp
- https://brunodelorence.com/experiments/book-shelf/bleach-2.webp
- https://brunodelorence.com/experiments/book-shelf/bleach-3.webp
- https://brunodelorence.com/experiments/book-shelf/bleach-4.webp
- https://brunodelorence.com/experiments/book-shelf/bleach-5.webp
- https://brunodelorence.com/experiments/book-shelf/bleach-6.webp
- https://brunodelorence.com/experiments/book-shelf/bleach-7.webp
- https://brunodelorence.com/experiments/book-shelf/bleach-8.webp
- https://brunodelorence.com/experiments/book-shelf/bleach-9.webp
- https://brunodelorence.com/experiments/book-shelf/bakuman-1.webp
- https://brunodelorence.com/experiments/book-shelf/bakuman-2.webp
- https://brunodelorence.com/experiments/book-shelf/bakuman-3.webp
- https://brunodelorence.com/experiments/book-shelf/bakuman-4.webp
- https://brunodelorence.com/experiments/book-shelf/bakuman-5.webp
- https://brunodelorence.com/experiments/book-shelf/bakuman-6.webp
- https://brunodelorence.com/experiments/book-shelf/bakuman-7.webp
- https://brunodelorence.com/experiments/book-shelf/bakuman-8.webp
- https://brunodelorence.com/experiments/book-shelf/bakuman-9.webp

## Usage

```tsx
import { BookShelf } from './book-shelf';
import { BOOKS, type Book } from './books';

// Keep the reader's place wherever you keep their progress.
function saveChapter(book: Book, chapter: number) {
  localStorage.setItem(`chapter:${book.id}`, String(chapter));
}

export function ReadingList() {
  return <BookShelf books={BOOKS} defaultTheme="dark" onRead={saveChapter} autoplay={false} />;
}
```

## States

- **Shelf**: Volumes grouped by chapters read: some, none or all; the groups scroll inside the shelf.
- **Lifted**: The volume under the pointer: solid, tilted, its reading details shown. Held still here.
- **Open**: The cover hinges back over the shelf; Continue reads the next chapter and calls `onRead`.
- **Open, completed**: Every chapter read: the button says Completed and does nothing.
- **Flat covers**: The cube off: no page edges and no tilt.
- **Filtered**: One group shown; the pill counts what is left.
- **Searching**: The field replaces the Search pill and filters every group as you type.
- **Group folded**: Its tab’s chevron turns; the covers go.
- **No match**: A search or filter that leaves no volumes.

## How it works

- The library contains 36 Japanese covers from the official Shueisha and Kodansha catalogs. Each keeps its original aspect ratio in the shelf and in the flying detail cover. Reading progress, chapter counts and personal ratings are illustrative demo state; the edition artwork and publication metadata remain traceable to their publisher sources.
- Each volume is CSS 3D: the cover at z = 0, with its page edges folding back from its sides, seen from an eye placed to the right of the book so the page block shows. At rest a volume is flat: a clipped pseudo-element paints the page edges at exactly the angle the 3D face would show, so it turns solid without a jump. Only the volume under the pointer or the focus, and one springing back, has 3D faces and will-change.
- Over a volume, the pointer’s position becomes a tilt: two custom properties, written straight on the element on every move, rotate it so that the corner under the pointer comes towards the reader. The volume steps forward, its shadow grows, and its genre, chapter and progress fade in on the cover; leaving springs it back on a CSS linear() spring.
- The tilt reads the pointer as a share of the volume’s own box, so it lands right even in a scaled container, and the demo’s pointer drives the same tilt along paths across the covers.
- Pressing a volume lifts the entire book from its shelf position with FLIP. A two-sided cover rotates 145 degrees around the spine: its front hides and its illustrated inside face appears on the left, while the reading details are exposed on the right page. Escape or Close reverses the hinge and returns the closed book to its original slot. An interrupted opening closes from the transform already on screen.
- The open book keeps the selected edition’s aspect ratio and adapts to the component’s width. Continue reads on chapter by chapter; the volume moves to its new group when it starts or finishes. The top bar still filters, searches, sorts and switches between themes and flat or 3D shelf covers.
- Its light and dark looks are light-dark() tokens, so the shelf opens in the page’s color-scheme, or the colorScheme prop, and the eye shows which one it is in; switching it is a pick that holds. The covers keep their colours in both.

## API reference

- **books** (`readonly Book[]`, default `BOOKS`): The volumes in their original order, with Japanese title, author, publication year, cover image and aspect ratio, publisher source and demo reading progress. Chapters read put each volume in Reading, Plan to read or Completed.
- **name** (`string`, default `'Library'`): The app’s name, in the top bar.
- **defaultTheme** (`'light' | 'dark'`): The theme at first, as if picked on the top bar’s eye. Without it the shelf follows the page’s color-scheme, or colorScheme.
- **defaultState** (`BookShelfState`): A shelf to open in, held until the reader changes it: open (a volume’s id, open as a book), lifted and at (a volume tilted as under the pointer, at shares of its size), showing (a group or 'all'), sort ('added' | 'title' | 'year'), query (the search open with this text, without taking the focus), collapsed (groups folded shut) and flat (flat covers). Turns the demo off.
- **onRead** (`(book: Book, chapter: number) => void`): Called with a volume and the chapter reached when the reader presses Continue.
- **onAdd** (`() => void`): Called when + Add is pressed: open your search for new volumes there.
- **autoplay** (`boolean`, default `true`): While on screen and untouched, a demo pointer runs over the covers, tilting them, opens one and closes it.
- **colorScheme** (`'light' | 'dark' | 'system'`): Its colours: light, dark, or the reader’s system setting. Without it the component follows the page’s color-scheme, which is light unless the page sets one.
- **className** (`string`): Extra class for the scrollable library surface.

## Accessibility

- Each group is a section named by its tab, a button that folds the group (aria-expanded). The volumes are buttons such as “Naruto, volume 1”, with reading progress in their description. The grid has one tab stop: arrow keys, Home and End move between volumes, including those further down the scrollable shelf, and focus lifts a volume like the pointer.
- The details panel is a modal dialog named by the volume’s title: focus moves to its close button and stays inside, Escape returns it to the volume, the library behind is inert, and Continue announces the chapter reached.
- The top bar’s controls are labelled: the filter is a select (“Showing”), the theme a switch (“Dark mode”), 3D covers and the sorts toggle buttons, and search a labelled field. The number of volumes a filter or a search leaves is announced politely.
- Under reduced motion nothing tilts or flies: the overlays still appear on hover, the panel appears at once, and there is no demo.

## Source

### book-shelf/book-shelf.tsx

```tsx
// Copyright (c) 2026 Bruno Delorence. MIT License.

'use client';

import {
  useEffect,
  useEffectEvent,
  useId,
  useLayoutEffect,
  useRef,
  useState,
  type CSSProperties,
  type KeyboardEvent,
  type PointerEvent,
  type RefObject,
} from 'react';
import { Volume } from './book';
import { BOOKS, STATUSES, type Book, type Status } from './books';
import { FrontCover } from './covers';
import { GhostPointer, runDemo } from './ghost-pointer';
import { ChevronIcon, CloseIcon, CubeIcon, EyeClosedIcon, EyeOpenIcon } from './icons';
import { easingOf, spring } from './spring';
import { useAutoplay } from './use-autoplay';
import styles from './book-shelf.module.css';

/*
 * Book shelf: the library view of a reading tracker, its volumes grouped by where the reader is
 * with them, each one drawn as a real paperback.
 *
 * - A volume is CSS 3D (book.tsx): its cover, with the block of page edges along its right side.
 *   At rest it is flat, the page edges painted at the angle the 3D face would show; only the
 *   volume under the pointer (or the focus) is solid and has `will-change`.
 * - Over a volume, the pointer's position sets its tilt: the corner under the pointer comes
 *   towards the reader, the volume lifts and its shadow grows, and its genre, chapter and
 *   progress show on the cover. The tilt is two custom properties written on every move; the
 *   transform follows them with a short transition, and springs back once the pointer leaves.
 * - Pressing a volume lifts the whole book from the grid (FLIP). Its front cover hinges open
 *   in 3D to reveal an inner cover and the reading details on the facing page.
 * - The top bar works: the filter, search, sorting, the theme and flat or 3D covers.
 * - While on screen and untouched, a ghost pointer runs over the covers, opens one and closes it.
 * - Light or dark: the app's colours are light-dark() tokens in the CSS, so it is drawn in the
 *   page's color-scheme (or the colorScheme prop's) from the first frame. The eye in the top bar
 *   shows that scheme, read once mounted, and pressing it sets the app's own.
 */

type Theme = 'light' | 'dark';
export type Sort = 'added' | 'title' | 'year';
type Phase = 'opening' | 'open' | 'closing';
type Opened = { id: string; phase: Phase; byReader: boolean };
type Point = { x: number; y: number };

/**
 * A state to open the shelf in, held until the reader changes it; any of it turns the demo off.
 * The volumes are named by their id.
 */
export type BookShelfState = {
  /** The volume open as a book, its cover hinged back. */
  open?: string;
  /** The volume lifted and tilted as if under the pointer… */
  lifted?: string;
  /** …at this place over it, as shares of its size (the middle when left out). */
  at?: Point;
  /** The filter: every group, or one. */
  showing?: Status | 'all';
  /** The order within the groups. */
  sort?: Sort;
  /** The search field, open with this text. */
  query?: string;
  /** The groups folded shut. */
  collapsed?: Status[];
  /** Flat covers instead of 3D volumes. */
  flat?: boolean;
};

/** How far a volume tilts after the pointer, in degrees, and lifts, in cover widths. */
const TILT = { x: 11, y: 15, lift: 0.26 };
/** The spring a volume settles back on. */
const BACK = spring(0.3, 560);
/** The cover's flight into the panel and back. */
const FLIGHT = spring(0.14, 520);

/** The stylesheet's numbers, from here: one source for the CSS and the animations. */
const ROOT_VARS = {
  '--spring': BACK.easing,
  '--spring-ms': `${BACK.duration}ms`,
  '--lift': TILT.lift,
} as CSSProperties;

const reducedMotion = () => window.matchMedia('(prefers-reduced-motion: reduce)').matches;

/**
 * The colour scheme `root` is drawn in: the CSS gives it an unseen colour, white in light and
 * black in dark, read once mounted and again whenever the scheme may have changed: an attribute
 * of an ancestor (where pages keep their theme), the system's setting, or `colorScheme`. null
 * until read: the server cannot know it.
 */
function useScheme(root: RefObject<HTMLElement | null>, colorScheme?: string) {
  const [scheme, setScheme] = useState<Theme | null>(null);
  useLayoutEffect(() => {
    const element = root.current;
    if (!element) return;
    const read = () =>
      setScheme(getComputedStyle(element).outlineColor === 'rgb(0, 0, 0)' ? 'dark' : 'light');
    read();
    const ancestors = new MutationObserver(read);
    for (let node = element.parentElement; node; node = node.parentElement) {
      ancestors.observe(node, { attributes: true });
    }
    const system = window.matchMedia('(prefers-color-scheme: dark)');
    system.addEventListener('change', read);
    return () => {
      ancestors.disconnect();
      system.removeEventListener('change', read);
    };
  }, [root, colorScheme]);
  return scheme;
}

/** Where a volume belongs: nothing read yet, part read, or done. */
const statusOf = (book: Book, read: number): Status =>
  read <= 0 ? 'plan' : read >= book.chapters ? 'done' : 'reading';

/** The pointer's place over an element, as shares of its size (scaled containers included). */
function shareOf(event: PointerEvent<HTMLElement>): Point {
  const box = event.currentTarget.getBoundingClientRect();
  return { x: (event.clientX - box.left) / box.width, y: (event.clientY - box.top) / box.height };
}

/** Where an element's transform has got to, mid-animation, so the next one starts from there. */
function seen(element: Element | null) {
  if (!element || !element.getAnimations().some((one) => one.playState === 'running')) {
    return null;
  }
  return getComputedStyle(element).transform;
}

export function BookShelf({
  books = BOOKS,
  name = 'Library',
  defaultTheme,
  colorScheme,
  onRead,
  onAdd,
  autoplay = true,
  defaultState,
  className = '',
}: {
  /** The volumes, in the order they were added; chapters read put each in its group. */
  books?: readonly Book[];
  /** The app's name, in the top bar. */
  name?: string;
  /** The theme at first, as if picked on the top bar's eye; without it, it follows colorScheme. */
  defaultTheme?: Theme;
  /** Light or dark colours, or 'system' for the reader's; without it, the page's color-scheme. */
  colorScheme?: 'light' | 'dark' | 'system';
  /** Called with a volume and the chapter reached when the reader presses Continue. */
  onRead?: (book: Book, chapter: number) => void;
  /** Called when + Add is pressed: open your search for new volumes there. */
  onAdd?: () => void;
  /** While on screen and untouched, a ghost pointer tilts the covers, opens one and closes it. */
  autoplay?: boolean;
  /** Opens in this state (a volume open or lifted, the filter, search, sort…) with the demo off. */
  defaultState?: BookShelfState;
  className?: string;
}) {
  const { ref: root, playing, take } = useAutoplay<HTMLDivElement>(6000);
  /** The theme picked on the eye; until then, the app follows its colour scheme. */
  const [picked, setPicked] = useState<Theme | undefined>(defaultTheme);
  const scheme = useScheme(root, colorScheme);
  const theme = picked ?? scheme ?? (colorScheme === 'dark' ? 'dark' : 'light');
  /** Volumes drawn as 3D objects, or as flat covers. */
  const [depth, setDepth] = useState(!defaultState?.flat);
  const [showing, setShowing] = useState<Status | 'all'>(defaultState?.showing ?? 'all');
  const [sort, setSort] = useState<Sort>(defaultState?.sort ?? 'added');
  const [searching, setSearching] = useState(defaultState?.query !== undefined);
  /** Whether the reader opened the search, so it takes the focus (one opened by defaultState doesn't). */
  const [searchFocus, setSearchFocus] = useState(false);
  const [query, setQuery] = useState(defaultState?.query ?? '');
  const [collapsed, setCollapsed] = useState<ReadonlySet<Status>>(
    () => new Set(defaultState?.collapsed),
  );
  /** Chapters read, per volume. */
  const [progress, setProgress] = useState<Readonly<Record<string, number>>>(() =>
    Object.fromEntries(books.map((book) => [book.id, book.chapter])),
  );
  /** The volume tilted after the pointer, or lifted by the focus. */
  const [hovered, setHovered] = useState<string | null>(defaultState?.lifted ?? null);
  /** Volumes springing back to rest: still solid until they land. */
  const [settling, setSettling] = useState<ReadonlySet<string>>(() => new Set());
  const [opened, setOpened] = useState<Opened | null>(() =>
    defaultState?.open ? { id: defaultState.open, phase: 'open', byReader: false } : null,
  );
  /** The grid's one tab stop: the arrow keys move it. */
  const [focusId, setFocusId] = useState<string | null>(null);
  const [message, setMessage] = useState('');
  const baseId = useId().replace(/[^\w-]/g, '');
  const hintId = `${baseId}-hint`;
  const panelTitleId = `${baseId}-title`;

  const buttons = useRef(new Map<string, HTMLButtonElement>());
  const panel = useRef<HTMLDivElement>(null);
  const slot = useRef<HTMLDivElement>(null);
  const flyer = useRef<HTMLDivElement>(null);
  const leaf = useRef<HTMLDivElement>(null);
  const closeButton = useRef<HTMLButtonElement>(null);
  const readButton = useRef<HTMLButtonElement>(null);
  const filter = useRef<HTMLSelectElement>(null);
  const pointer = useRef<SVGSVGElement>(null);
  /** The latest state, for the handlers and the demo, which run between renders. */
  const live = useRef({ hovered, opened });
  /** The volume the keyboard is on, if its focus shows. */
  const keyboard = useRef<string | null>(null);
  /** The volume the demo's pointer is over. */
  const demoHeld = useRef<string | null>(null);
  const settleTimers = useRef(new Map<string, number>());
  /** Where the cover's flight starts: the volume's cover on the grid. */
  const departure = useRef<DOMRect | null>(null);
  /** The volume whose panel took the focus, and the volume to give it back to. */
  const entered = useRef<string | null>(null);
  const refocus = useRef<string | null>(null);
  /** Where the cover was when a close interrupted the opening. */
  const interrupted = useRef<string | null>(null);
  const interruptedLeaf = useRef<string | null>(null);
  const running = useRef(new Set<Animation>());

  useEffect(() => {
    live.current = { hovered, opened };
  });

  useEffect(() => {
    const timers = settleTimers.current;
    const animations = running.current;
    return () => {
      timers.forEach((timer) => window.clearTimeout(timer));
      animations.forEach((animation) => animation.cancel());
    };
  }, []);

  /** Repeats are made to differ, so screen readers announce them again. */
  const announce = (text: string) => setMessage((now) => (now === text ? `${text}\u00a0` : text));

  const bookOf = (id: string | null | undefined) => books.find((book) => book.id === id);
  const readOf = (book: Book) => progress[book.id] ?? book.chapter;

  /** Plays an animation and keeps track of it, so that it can be cancelled. */
  const play = (
    element: Element | null,
    keyframes: Keyframe[],
    options: KeyframeAnimationOptions,
  ) => {
    if (!element) return null;
    const animation = element.animate(keyframes, options);
    running.current.add(animation);
    const forget = () => running.current.delete(animation);
    animation.finished.then(forget, forget);
    return animation;
  };

  /** All of them done: resolves true, or false if one was cancelled. */
  const allDone = (animations: (Animation | null)[]) =>
    Promise.all(animations.map((one) => one?.finished)).then(
      () => true,
      () => false,
    );

  /** Screen pixels to the component's own, should it sit in a scaled container. */
  const scaleOf = () => {
    const element = root.current;
    return element ? element.getBoundingClientRect().width / element.offsetWidth || 1 : 1;
  };

  /** Lifts a volume (or none); the one that was lifted springs back, solid until it lands. */
  const hover = (id: string | null) => {
    const before = live.current.hovered;
    if (before === id) return;
    live.current.hovered = id;
    setHovered(id);
    setSettling((now) => {
      const next = new Set(now);
      if (before) next.add(before);
      if (id) next.delete(id);
      return next;
    });
    if (id) {
      window.clearTimeout(settleTimers.current.get(id));
      settleTimers.current.delete(id);
    }
    if (before) {
      window.clearTimeout(settleTimers.current.get(before));
      settleTimers.current.set(
        before,
        window.setTimeout(() => {
          settleTimers.current.delete(before);
          setSettling((now) => {
            const next = new Set(now);
            next.delete(before);
            return next;
          });
        }, BACK.duration + 80),
      );
    }
  };

  /** Tilts a volume after a point over it: the corner under the point comes forward. */
  const lean = (id: string, at: Point) => {
    const button = buttons.current.get(id);
    if (!button) return;
    const x = Math.min(1, Math.max(0, at.x));
    const y = Math.min(1, Math.max(0, at.y));
    button.style.setProperty('--rx', `${((y - 0.5) * 2 * TILT.x).toFixed(2)}deg`);
    button.style.setProperty('--ry', `${(-(x - 0.5) * 2 * TILT.y).toFixed(2)}deg`);
  };

  // defaultState's lifted volume: tilted once, as the pointer would have left it.
  const liftAtFirst = useEffectEvent(() => {
    if (defaultState?.lifted) lean(defaultState.lifted, defaultState.at ?? { x: 0.5, y: 0.5 });
  });
  useLayoutEffect(() => liftAtFirst(), []);

  const open = (book: Book, byReader: boolean) => {
    const before = live.current.opened;
    if (before?.byReader || (before && !byReader)) return;
    if (before?.id === book.id && before.phase !== 'closing') {
      const next = { ...before, byReader };
      live.current.opened = next;
      setOpened(next);
      return;
    }
    // A real click takes over an automatic opening or closing in the same click.
    running.current.forEach((animation) => animation.cancel());
    const cover = buttons.current.get(book.id)?.querySelector('[data-cover]');
    departure.current = cover ? cover.getBoundingClientRect() : null;
    interrupted.current = null;
    interruptedLeaf.current = null;
    const next: Opened = { id: book.id, phase: reducedMotion() ? 'open' : 'opening', byReader };
    live.current.opened = next;
    setOpened(next);
    hover(null);
  };

  const close = (byReader: boolean) => {
    const now = live.current.opened;
    if (!now || now.phase === 'closing' || (!byReader && now.byReader)) return;
    if (byReader || panel.current?.contains(document.activeElement)) refocus.current = now.id;
    if (reducedMotion()) {
      live.current.opened = null;
      setOpened(null);
      return;
    }
    // A close that interrupts the opening starts from where the cover is.
    interrupted.current = seen(flyer.current);
    interruptedLeaf.current = seen(leaf.current);
    running.current.forEach((animation) => animation.cancel());
    const next: Opened = { ...now, phase: 'closing' };
    live.current.opened = next;
    setOpened(next);
  };

  const claimPanel = () => {
    const now = live.current.opened;
    if (!now || now.byReader) return;
    const next = { ...now, byReader: true };
    live.current.opened = next;
    setOpened(next);
  };

  /** Reads on: one more chapter of the open volume. */
  const readOn = () => {
    const book = bookOf(live.current.opened?.id);
    if (!book) return;
    const read = readOf(book);
    if (read >= book.chapters) return;
    setProgress((all) => ({ ...all, [book.id]: read + 1 }));
    onRead?.(book, read + 1);
    announce(
      read + 1 === book.chapters
        ? `${book.title} completed.`
        : `Chapter ${read + 1} of ${book.chapters}.`,
    );
  };

  // The focus moves into the panel when the reader opens a volume, and back to it after.
  useLayoutEffect(() => {
    if (!opened) entered.current = null;
    else if (opened.byReader && opened.phase !== 'closing' && entered.current !== opened.id) {
      entered.current = opened.id;
      closeButton.current?.focus({ preventScroll: true });
    }
    if (refocus.current && (!opened || opened.phase === 'closing')) {
      const target =
        buttons.current.get(refocus.current) ??
        buttons.current.values().next().value ??
        filter.current;
      target?.focus({ preventScroll: true });
      refocus.current = null;
    }
  }, [opened]);

  // The closed cover lies over the right page. Lift that footprint from the selected volume,
  // then hinge it about the spine; its reverse is the illustrated inside cover.
  const playOpening = useEffectEvent(() => {
    const element = flyer.current;
    const now = live.current.opened;
    if (!element || !now) return;
    const from = departure.current;
    const box = slot.current?.getBoundingClientRect();
    if (!box) return;
    const scale = scaleOf();
    const animations = [
      play(panel.current, [{ opacity: 0 }, { opacity: 1 }], { duration: 120, easing: 'ease-out' }),
      from
        ? play(
            element,
            [
              {
                transform: `translate(${(from.left - box.left) / scale}px, ${(from.top - box.top) / scale}px) scale(${from.width / box.width})`,
              },
              { transform: 'none' },
            ],
            { duration: FLIGHT.duration, easing: easingOf(FLIGHT) },
          )
        : null,
      play(leaf.current, [{ transform: 'rotateY(0deg)' }, { transform: 'rotateY(-145deg)' }], {
        duration: FLIGHT.duration,
        delay: 80,
        easing: easingOf(FLIGHT),
        fill: 'backwards',
      }),
    ];
    allDone(animations).then((done) => {
      const later = live.current.opened;
      if (!done || later?.id !== now.id || later.phase !== 'opening') return;
      const next: Opened = { ...later, phase: 'open' };
      live.current.opened = next;
      setOpened(next);
    });
  });

  // Close the cover about its spine, and return the entire book to its original shelf slot.
  const playClosing = useEffectEvent(() => {
    const element = flyer.current;
    const now = live.current.opened;
    const cover = now && buttons.current.get(now.id)?.querySelector('[data-cover]');
    if (!element || !now) return;
    const box = slot.current?.getBoundingClientRect();
    if (!box) return;
    const scale = scaleOf();
    const to = cover?.getBoundingClientRect();
    const animations = [
      play(
        panel.current,
        [
          { opacity: 1, offset: 0 },
          { opacity: 1, offset: 0.86 },
          { opacity: 0, offset: 1 },
        ],
        { duration: FLIGHT.duration, delay: 100, easing: 'ease-in', fill: 'forwards' },
      ),
      to
        ? play(
            element,
            [
              { transform: interrupted.current ?? 'none' },
              {
                transform: `translate(${(to.left - box.left) / scale}px, ${(to.top - box.top) / scale}px) scale(${to.width / box.width})`,
              },
            ],
            { duration: FLIGHT.duration, delay: 100, easing: easingOf(FLIGHT), fill: 'forwards' },
          )
        : play(element, [{ opacity: 1 }, { opacity: 0 }], { duration: 220, fill: 'forwards' }),
      play(
        leaf.current,
        [
          { transform: interruptedLeaf.current ?? 'rotateY(-145deg)' },
          { transform: 'rotateY(0deg)' },
        ],
        {
          duration: 300,
          easing: 'cubic-bezier(0.4, 0, 0.2, 1)',
          fill: 'forwards',
        },
      ),
    ];
    allDone(animations).then((done) => {
      if (!done || live.current.opened?.id !== now.id) return;
      live.current.opened = null;
      setOpened(null);
      // Back on the grid with the focus on it: now it may lift again.
      if (keyboard.current === now.id) hover(now.id);
    });
  });

  useLayoutEffect(() => {
    if (opened?.phase === 'opening') playOpening();
    else if (opened?.phase === 'closing') playClosing();
  }, [opened?.phase, opened?.id]);

  // The library as shown: filtered, searched, sorted, grouped.
  const needle = query.trim().toLowerCase();
  const matches = (book: Book) =>
    !needle ||
    [book.title, book.author, book.native, book.genre].some((text) =>
      text.toLowerCase().includes(needle),
    );
  const sorted = [...books].sort((a, b) =>
    sort === 'title' ? a.title.localeCompare(b.title) : sort === 'year' ? b.year - a.year : 0,
  );
  const groups = STATUSES.filter((status) => showing === 'all' || showing === status.id)
    .map((status) => ({
      ...status,
      books: sorted.filter((book) => statusOf(book, readOf(book)) === status.id && matches(book)),
    }))
    .filter((group) => group.books.length);
  const shown = groups.flatMap((group) => (collapsed.has(group.id) ? [] : group.books));
  const shownCount = groups.reduce((sum, group) => sum + group.books.length, 0);
  const tabStop = shown.some((book) => book.id === focusId) ? focusId : (shown[0]?.id ?? null);

  /** Tells screen readers how many volumes the filter or the search leaves. */
  const tellCount = useEffectEvent(() =>
    announce(shownCount ? `${shownCount} volume${shownCount === 1 ? '' : 's'}.` : 'No volumes.'),
  );
  const filterKey = `${showing}|${needle}`;
  const firstFilter = useRef(filterKey);
  useEffect(() => {
    if (filterKey === firstFilter.current) return;
    firstFilter.current = filterKey;
    const timer = window.setTimeout(tellCount, 500);
    return () => window.clearTimeout(timer);
  }, [filterKey]);

  // The demo: the pointer runs over the first covers, opens one, closes it, then runs over the
  // next group's covers.
  const demoAct = useEffectEvent(
    (
      act:
        | { type: 'lean'; id: string; at: Point }
        | { type: 'rest' }
        | { type: 'open'; id: string }
        | { type: 'close' },
    ) => {
      if (act.type === 'lean') {
        demoHeld.current = act.id;
        hover(act.id);
        lean(act.id, act.at);
      } else if (act.type === 'rest') {
        demoHeld.current = null;
        hover(null);
      } else if (act.type === 'open') {
        const book = bookOf(act.id);
        if (book) open(book, false);
      } else close(false);
    },
  );
  const readShown = useEffectEvent(() => {
    const viewport = root.current?.querySelector(`.${styles.content}`)?.getBoundingClientRect();
    if (!viewport) return [];
    return shown
      .filter((book) => {
        const box = buttons.current.get(book.id)?.getBoundingClientRect();
        if (!box) return false;
        const visible = Math.min(box.bottom, viewport.bottom) - Math.max(box.top, viewport.top);
        return visible >= box.height / 2;
      })
      .map((book) => book.id);
  });
  // What the demo left lifted or open goes back; what the reader has taken stays.
  const endDemo = useEffectEvent(() => {
    if (demoHeld.current && live.current.hovered === demoHeld.current) hover(keyboard.current);
    demoHeld.current = null;
    if (live.current.opened && !live.current.opened.byReader) close(false);
  });
  const demo = autoplay && !defaultState && playing && !opened?.byReader && !searching;
  useEffect(() => {
    const stage = root.current;
    const arrow = pointer.current;
    if (!demo || !stage || !arrow) return;
    const stop = runDemo(stage, arrow, async ({ wait, moveTo, trace, press, leave }) => {
      const paths: Point[][] = [
        [
          { x: 0.25, y: 0.72 },
          { x: 0.82, y: 0.28 },
          { x: 0.62, y: 0.8 },
        ],
        [
          { x: 0.2, y: 0.3 },
          { x: 0.78, y: 0.66 },
        ],
        [
          { x: 0.3, y: 0.42 },
          { x: 0.64, y: 0.58 },
        ],
      ];
      for (let round = 0; ; round += 1) {
        await wait(round ? 1400 : 900);
        const ids = readShown();
        const from = (round * 3) % Math.max(1, ids.length - 2);
        const run = ids.slice(from, from + 3);
        for (const [i, id] of run.entries()) {
          const target = buttons.current.get(id);
          const path = paths[i];
          await moveTo(target, path[0]);
          await trace(target, path, i === 0 ? 2000 : 1300, (at) =>
            demoAct({ type: 'lean', id, at }),
          );
        }
        const pick = run[run.length - 1];
        if (!pick) return;
        // The spread fits the library's height, including the compact gallery preview.
        await press(() => demoAct({ type: 'open', id: pick }));
        await wait(1500);
        await moveTo(readButton.current);
        await wait(900);
        await moveTo(closeButton.current);
        await press(() => demoAct({ type: 'close' }));
        await wait(700);
        demoAct({ type: 'rest' });
        await leave();
      }
    });
    return () => {
      stop();
      endDemo();
    };
  }, [demo, root]);

  /** Left and right move along the volumes; up and down to the row above or below. */
  const onBookKey = (event: KeyboardEvent<HTMLButtonElement>, id: string) => {
    const index = shown.findIndex((book) => book.id === id);
    let next = index;
    if (event.key === 'ArrowRight') next = index + 1;
    else if (event.key === 'ArrowLeft') next = index - 1;
    else if (event.key === 'Home') next = 0;
    else if (event.key === 'End') next = shown.length - 1;
    else if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
      const boxes = shown.map((book) => buttons.current.get(book.id)?.getBoundingClientRect());
      const here = boxes[index];
      if (!here) return;
      const down = event.key === 'ArrowDown';
      let best = -1;
      let bestScore = Infinity;
      boxes.forEach((box, i) => {
        if (!box) return;
        const gap = down ? box.top - here.bottom : here.top - box.bottom;
        if (gap < 0) return;
        const score = gap * 4 + Math.abs(box.left + box.width / 2 - (here.left + here.width / 2));
        if (score < bestScore) {
          bestScore = score;
          best = i;
        }
      });
      if (best < 0) return;
      next = best;
    } else return;
    event.preventDefault();
    const target = shown[Math.min(shown.length - 1, Math.max(0, next))];
    if (target) buttons.current.get(target.id)?.focus();
  };

  /** Escape closes the panel; Tab stays inside it. */
  const onPanelKey = (event: KeyboardEvent<HTMLDivElement>) => {
    if (event.key === 'Escape') {
      event.preventDefault();
      close(true);
    } else if (event.key === 'Tab') {
      const stops = [closeButton.current, readButton.current].filter(
        (element): element is HTMLButtonElement => !!element,
      );
      const at = stops.indexOf(document.activeElement as HTMLButtonElement);
      event.preventDefault();
      stops[(at + (event.shiftKey ? -1 : 1) + stops.length) % stops.length]?.focus();
    }
  };

  const book = bookOf(opened?.id);
  const read = book ? readOf(book) : 0;
  const finished = !!book && read >= book.chapters;

  return (
    <div
      ref={root}
      data-manga-library=""
      className={`${styles.root} ${className}`}
      data-color-scheme={picked ?? colorScheme}
      style={ROOT_VARS}
      onPointerDownCapture={take}
      onKeyDownCapture={take}
    >
      <div className={styles.app} data-depth={depth ? '3d' : 'flat'}>
        <div className={styles.library} inert={!!opened?.byReader && opened.phase !== 'closing'}>
          <header className={styles.bar} data-part="bar">
            <p className={styles.brand} data-part="name">
              {name}
              <ChevronIcon className={styles.brandChevron} />
            </p>
            <label className={styles.showing} data-part="filter">
              <span className={styles.muted}>Showing:</span>
              <span>
                {showing === 'all'
                  ? 'All'
                  : STATUSES.find((status) => status.id === showing)?.label}
              </span>
              <span className={styles.count}>{shownCount}</span>
              <ChevronIcon className={styles.chevron} />
              <select
                ref={filter}
                className={styles.select}
                aria-label="Showing"
                value={showing}
                onChange={(event) => setShowing(event.target.value as Status | 'all')}
              >
                <option value="all">All</option>
                {STATUSES.map((status) => (
                  <option key={status.id} value={status.id}>
                    {status.label}
                  </option>
                ))}
              </select>
            </label>
            <div className={styles.tools}>
              <button
                type="button"
                role="switch"
                aria-checked={theme === 'dark'}
                aria-label="Dark mode"
                className={styles.round}
                data-part="theme"
                onClick={() => setPicked(theme === 'dark' ? 'light' : 'dark')}
              >
                {theme === 'dark' ? <EyeOpenIcon /> : <EyeClosedIcon />}
              </button>
              <button
                type="button"
                aria-pressed={depth}
                aria-label="3D covers"
                className={styles.round}
                data-part="depth"
                onClick={() => setDepth((now) => !now)}
              >
                <CubeIcon />
              </button>
              {searching ? (
                <input
                  className={styles.search}
                  data-part="search"
                  type="search"
                  placeholder="Title, author…"
                  aria-label="Search volumes"
                  autoComplete="off"
                  spellCheck={false}
                  value={query}
                  autoFocus={searchFocus}
                  onChange={(event) => setQuery(event.target.value)}
                  onKeyDown={(event) => {
                    if (event.key !== 'Escape') return;
                    event.preventDefault();
                    setQuery('');
                    setSearching(false);
                  }}
                  onBlur={() => {
                    if (!query.trim()) setSearching(false);
                  }}
                />
              ) : (
                <button
                  type="button"
                  className={styles.pill}
                  data-part="search"
                  onClick={() => {
                    setSearchFocus(true);
                    setSearching(true);
                  }}
                >
                  Search
                </button>
              )}
              <button
                type="button"
                className={`${styles.pill} ${styles.sort}`}
                aria-pressed={sort === 'title'}
                aria-label="Sort by title"
                data-part="sort"
                onClick={() => setSort((now) => (now === 'title' ? 'added' : 'title'))}
              >
                A→Z
              </button>
              <button
                type="button"
                className={`${styles.pill} ${styles.sort}`}
                aria-pressed={sort === 'year'}
                aria-label="Sort by year"
                data-part="sort"
                onClick={() => setSort((now) => (now === 'year' ? 'added' : 'year'))}
              >
                Year
              </button>
              <button type="button" className={styles.pill} data-part="add" onClick={onAdd}>
                <span className={styles.plus}>+</span> Add
              </button>
            </div>
          </header>
          <div className={styles.content} onScroll={take}>
            {groups.map((group) => {
              const folded = collapsed.has(group.id);
              return (
                <section
                  key={group.id}
                  className={styles.group}
                  aria-labelledby={`${baseId}-${group.id}`}
                >
                  <button
                    id={`${baseId}-${group.id}`}
                    type="button"
                    className={styles.tab}
                    data-part="group-tab"
                    aria-expanded={!folded}
                    aria-controls={`${baseId}-${group.id}-list`}
                    onClick={() =>
                      setCollapsed((now) => {
                        const next = new Set(now);
                        if (folded) next.delete(group.id);
                        else next.add(group.id);
                        return next;
                      })
                    }
                  >
                    <span className={styles.tabLabel}>{group.label}</span>
                    <span className={styles.tabCount}>{group.books.length}</span>
                    <ChevronIcon className={styles.tabChevron} />
                  </button>
                  <ul
                    id={`${baseId}-${group.id}-list`}
                    className={styles.grid}
                    data-part="grid"
                    aria-label={group.label}
                    hidden={folded}
                  >
                    {group.books.map((one) => {
                      const solid = depth && (hovered === one.id || settling.has(one.id));
                      const done = readOf(one);
                      return (
                        <li key={one.id} className={styles.slot} data-awake={solid || undefined}>
                          <button
                            ref={(element) => {
                              if (element) buttons.current.set(one.id, element);
                              else buttons.current.delete(one.id);
                            }}
                            type="button"
                            className={styles.book}
                            data-part="volume"
                            style={{ '--cover-ratio': one.coverRatio ?? 1.5625 } as CSSProperties}
                            data-pose={hovered === one.id ? 'tilt' : 'rest'}
                            data-open={opened?.id === one.id || undefined}
                            aria-label={`${one.title}, volume ${one.volume}`}
                            aria-describedby={`${baseId}-${one.id}-about ${hintId}`}
                            tabIndex={tabStop === one.id ? 0 : -1}
                            onClick={() => open(one, true)}
                            onPointerEnter={(event) => {
                              if (event.pointerType === 'touch') return;
                              take();
                              hover(one.id);
                              lean(one.id, shareOf(event));
                            }}
                            onPointerMove={(event) => {
                              if (event.pointerType !== 'touch') lean(one.id, shareOf(event));
                            }}
                            onPointerLeave={(event) => {
                              if (event.pointerType === 'touch') return;
                              const back = keyboard.current;
                              hover(back);
                              if (back) lean(back, { x: 0.5, y: 0.5 });
                            }}
                            onFocus={(event) => {
                              setFocusId(one.id);
                              if (!event.currentTarget.matches(':focus-visible')) return;
                              keyboard.current = one.id;
                              if (live.current.opened?.id === one.id) return;
                              hover(one.id);
                              lean(one.id, { x: 0.5, y: 0.5 });
                            }}
                            onBlur={() => {
                              if (keyboard.current !== one.id) return;
                              keyboard.current = null;
                              if (live.current.hovered === one.id) hover(null);
                            }}
                            onKeyDown={(event) => onBookKey(event, one.id)}
                          >
                            <span className={styles.shadow} />
                            <Volume
                              book={one}
                              read={done}
                              solid={solid}
                              id={`${baseId}-${one.id}`}
                            />
                          </button>
                          <span id={`${baseId}-${one.id}-about`} className={styles.hidden}>
                            {one.genre}. Chapter {done} of {one.chapters}.
                            {one.rating !== undefined && ` Rated ${one.rating} out of 10.`}
                          </span>
                        </li>
                      );
                    })}
                  </ul>
                </section>
              );
            })}
            {!groups.length && (
              <p className={styles.empty} data-part="empty">
                No volumes match.
              </p>
            )}
          </div>
        </div>
        {opened && book && (
          <>
            <div
              ref={panel}
              className={styles.panel}
              data-open-book=""
              data-part="open-book"
              style={{ '--open-ratio': book.coverRatio ?? 1.5625 } as CSSProperties}
              role="dialog"
              aria-modal={opened.byReader || undefined}
              aria-labelledby={panelTitleId}
              onPointerDownCapture={claimPanel}
              onFocusCapture={claimPanel}
              onKeyDown={onPanelKey}
            >
              <div ref={flyer} className={styles.spread}>
                <div
                  ref={leaf}
                  className={styles.coverLeaf}
                  data-part="cover-leaf"
                  aria-hidden="true"
                >
                  <div className={styles.leafFront}>
                    <FrontCover book={book} id={`${baseId}-front`} className={styles.art} />
                  </div>
                  <div className={styles.leafInside}>
                    <div className={styles.insideArt}>
                      <FrontCover book={book} id={`${baseId}-inside`} className={styles.art} />
                    </div>
                    <p className={styles.insideNative} lang="ja">
                      {book.native}
                    </p>
                    <span className={styles.insideEdition}>
                      Japanese edition · Vol. {book.volume}
                    </span>
                  </div>
                </div>
                <div ref={slot} className={styles.panelPage} data-part="page">
                  <div className={styles.panelHead}>
                    <span className={styles.panelChip} data-part="genre-chip">
                      {book.genre}
                    </span>
                    <span className={styles.muted}>
                      Vol. {String(book.volume).padStart(2, '0')}
                    </span>
                    <button
                      ref={closeButton}
                      type="button"
                      className={styles.round}
                      data-part="close"
                      aria-label={`Close ${book.title}`}
                      onClick={() => close(true)}
                    >
                      <CloseIcon />
                    </button>
                  </div>
                  <div className={styles.panelMain}>
                    <div className={styles.panelText}>
                      <h2 id={panelTitleId} className={styles.panelTitle}>
                        {book.title}
                      </h2>
                      <p className={styles.panelNative} lang="ja">
                        {book.native}
                      </p>
                      <p className={styles.muted}>
                        {book.author} · {book.year}
                      </p>
                      {book.rating !== undefined && (
                        <p className={styles.panelRating}>
                          ★ {book.rating}
                          <span className={styles.hidden}> out of 10</span>
                        </p>
                      )}
                    </div>
                  </div>
                  <div className={styles.panelProgress}>
                    <p className={styles.panelLine}>
                      <span>{finished ? 'Completed' : `Chapter ${read} of ${book.chapters}`}</span>
                      <span className={styles.muted}>
                        {Math.round((read / book.chapters) * 100)}%
                      </span>
                    </p>
                    <div
                      className={styles.panelTrack}
                      data-part="progress"
                      role="progressbar"
                      aria-label="Reading progress"
                      aria-valuemin={0}
                      aria-valuemax={book.chapters}
                      aria-valuenow={read}
                      aria-valuetext={`Chapter ${read} of ${book.chapters}`}
                    >
                      <span
                        className={styles.panelFill}
                        style={{ '--share': read / book.chapters } as CSSProperties}
                      />
                    </div>
                  </div>
                  <div className={styles.panelChapters}>
                    <p className={styles.muted}>Chapters</p>
                    <ol className={styles.chapters} data-part="chapters" aria-hidden="true">
                      {Array.from({ length: book.chapters }, (_, i) => (
                        <li
                          key={i}
                          data-state={i < read ? 'read' : i === read ? 'next' : undefined}
                        />
                      ))}
                    </ol>
                  </div>
                  <button
                    ref={readButton}
                    type="button"
                    className={styles.continue}
                    data-part="continue"
                    aria-disabled={finished || undefined}
                    onClick={readOn}
                  >
                    {finished ? 'Completed' : read ? `Continue · Ch ${read + 1}` : 'Start · Ch 1'}
                  </button>
                </div>
              </div>
            </div>
          </>
        )}
      </div>
      <GhostPointer ref={pointer} />
      <p className={styles.hidden} role="status">
        {message}
      </p>
      <p id={hintId} className={styles.hidden}>
        Arrow keys move between volumes; Enter opens one.
      </p>
    </div>
  );
}
```

### book-shelf/book-shelf.module.css

```css
/* A manga library: Japanese covers on a continuous, scrollable surface. The app's data-depth
   draws the volumes as objects or flat covers. A volume's sizes are in --w, its cover's width
   (each grid cell is a container); the tilt is --rx and --ry, written by the component as the
   pointer moves.

   The app's colours are the tokens on .app, light-dark(light, dark): it takes the color-scheme
   it inherits from the page, or the one the colorScheme prop or the eye in the top bar sets
   (data-color-scheme on the root). The covers keep their own colours on both. */

.root {
  --display:
    'Avenir Next Condensed', 'Roboto Condensed', 'Arial Narrow', 'Helvetica Neue', Impact,
    sans-serif;
  --mono: ui-monospace, 'SF Mono', 'JetBrains Mono', Menlo, Consolas, monospace;
  --focus: #0a84ff;
  /* For the script: a colour the root never shows (it has no outline), white in light and black
     in dark, which tells the eye the scheme the root is drawn in. */
  outline-color: #fff;
  position: relative;
  container: stage / inline-size;
  display: grid;
  grid-template-rows: minmax(0, 1fr);
  height: var(--shelf-height, 660px);
  overflow: hidden;
  isolation: isolate;
  font:
    400 13px/1.35 -apple-system,
    BlinkMacSystemFont,
    'SF Pro Text',
    Inter,
    'Segoe UI',
    sans-serif;
  -webkit-font-smoothing: antialiased;
  -webkit-tap-highlight-color: transparent;
}
@supports (color: light-dark(#fff, #000)) {
  .root {
    outline-color: light-dark(#fff, #000);
  }
}
/* The colorScheme prop, or the reader's pick on the eye. Without either, nothing is set:
   color-scheme is inherited from the page. */
.root[data-color-scheme='light'] {
  color-scheme: light;
}
.root[data-color-scheme='dark'] {
  color-scheme: dark;
}
.root[data-color-scheme='system'] {
  color-scheme: light dark;
}
.root :where(button, input, select) {
  font: inherit;
  color: inherit;
}

/* The app's window, light or dark. */
.app {
  --app: #f4f4f4;
  --bar: #fff;
  --pill: #f1f1f1;
  --chip: #e1e1e1;
  --ink: #141414;
  --soft: #4a4a4a;
  --muted: #8c8c8c;
  --line: rgb(0 0 0 / 0.07);
  --tab: #fff;
  --panel: #fff;
  --edge: #dcdcdc;
  --cast: rgb(0 0 0 / 0.3);
  --bar-h: 50px;
  position: relative;
  container: app / size;
  box-sizing: border-box;
  width: 100%;
  height: 100%;
  min-height: 0;
  min-width: 0;
  background: var(--app);
  color: var(--ink);
  transition:
    background-color 0.3s ease,
    color 0.3s ease;
}
@supports (color: light-dark(#fff, #000)) {
  /* The app's window, light or dark. */
  .app {
    --app: light-dark(#f4f4f4, #050505);
    --bar: light-dark(#fff, #0d0d0d);
    --pill: light-dark(#f1f1f1, #1c1c1c);
    --chip: light-dark(#e1e1e1, #2f2f2f);
    --ink: light-dark(#141414, #f2f2f2);
    --soft: light-dark(#4a4a4a, #c8c8c8);
    --muted: light-dark(#8c8c8c, #8a8a8a);
    --line: light-dark(rgb(0 0 0 / 0.07), rgb(255 255 255 / 0.07));
    --tab: light-dark(#fff, #151515);
    --panel: light-dark(#fff, #131313);
    --edge: light-dark(#dcdcdc, #2a2a2a);
    --cast: light-dark(rgb(0 0 0 / 0.3), rgb(0 0 0 / 0.6));
  }
}
.library {
  display: flex;
  flex-direction: column;
  height: 100%;
}

/* The top bar. */
.bar {
  display: flex;
  align-items: center;
  gap: 10px;
  box-sizing: border-box;
  height: var(--bar-h);
  flex: none;
  padding: 0 max(12px, calc((100% - 696px) / 2)) 0 max(18px, calc((100% - 696px) / 2));
  background: var(--bar);
  box-shadow: 0 1px 0 var(--line);
}
.brand {
  display: flex;
  flex: none;
  align-items: center;
  gap: 6px;
  margin: 0 12px 0 0;
  font-family: var(--display);
  font-size: 19px;
  font-stretch: condensed;
  font-weight: 800;
  letter-spacing: 0.16em;
  line-height: 1;
  text-transform: uppercase;
}
.brandChevron {
  width: 13px;
  height: 13px;
  color: var(--muted);
}
.showing,
.pill,
.tab,
.panelChip,
.continue {
  font-family: var(--mono);
  font-weight: 700;
  letter-spacing: 0.12em;
  text-transform: uppercase;
}
.showing {
  position: relative;
  display: inline-flex;
  flex: none;
  align-items: center;
  gap: 7px;
  height: 30px;
  padding: 0 8px 0 12px;
  border-radius: 8px;
  background: var(--pill);
  font-size: 10.5px;
}
.count {
  padding: 3px 5px;
  border-radius: 4px;
  background: var(--chip);
}
.chevron {
  width: 13px;
  height: 13px;
  color: var(--muted);
}
/* The filter is a native select, invisible over the pill. */
.select {
  position: absolute;
  inset: 0;
  width: 100%;
  opacity: 0;
  cursor: pointer;
}
.showing:has(.select:focus-visible) {
  outline: 2px solid var(--focus);
  outline-offset: 2px;
}
.tools {
  display: flex;
  align-items: center;
  gap: 6px;
  margin-left: auto;
}
.round {
  display: grid;
  flex: none;
  place-items: center;
  width: 30px;
  height: 30px;
  padding: 0;
  border: 0;
  border-radius: 50%;
  background: var(--pill);
  color: var(--soft);
  cursor: pointer;
}
.round svg {
  width: 16px;
  height: 16px;
}
.round[aria-pressed='false'] {
  color: var(--muted);
  opacity: 0.6;
}
.pill {
  flex: none;
  height: 30px;
  padding: 0 12px;
  border: 0;
  border-radius: 8px;
  background: var(--pill);
  color: var(--soft);
  font-size: 10.5px;
  cursor: pointer;
}
.pill[aria-pressed='true'] {
  background: var(--ink);
  color: var(--bar);
}
.plus {
  color: #e8322d;
}
.search {
  box-sizing: border-box;
  width: 150px;
  height: 30px;
  padding: 0 10px;
  border: 0;
  border-radius: 8px;
  outline: none;
  background: var(--pill);
  font-family: var(--mono);
  font-size: 11px;
}
.search:focus-visible {
  box-shadow: 0 0 0 2px var(--focus);
}
.muted {
  margin: 0;
  color: var(--muted);
}

/* The groups: a tab on the left of each, its volumes in a grid of four. */
.content {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 40px;
  flex: 1;
  min-height: 0;
  overflow-y: auto;
  overscroll-behavior: contain;
  scrollbar-width: thin;
  scrollbar-color: var(--edge) transparent;
  padding: 28px 24px 40px;
}
.group {
  display: grid;
  grid-template-columns: 24px minmax(0, 740px);
  justify-content: center;
  gap: 20px;
  width: 100%;
}
.tab {
  display: flex;
  flex-direction: column;
  align-items: center;
  align-self: start;
  gap: 8px;
  width: 24px;
  padding: 12px 0 8px;
  border: 0;
  border-radius: 12px;
  background: var(--tab);
  box-shadow: 0 1px 3px rgb(0 0 0 / 0.07);
  color: var(--muted);
  font-size: 9px;
  cursor: pointer;
}
.tabLabel,
.tabCount {
  writing-mode: vertical-rl;
}
.tabCount {
  color: var(--ink);
}
.tabChevron {
  width: 12px;
  height: 12px;
  transition: transform 0.25s ease;
}
.tab[aria-expanded='false'] .tabChevron {
  transform: rotate(-90deg);
}
.grid {
  display: grid;
  grid-template-columns: repeat(4, minmax(0, 1fr));
  gap: 24px 20px;
  margin: 0;
  padding: 0;
  list-style: none;
}
.grid[hidden] {
  display: none;
}
.slot {
  position: relative;
  container-type: inline-size;
}
.slot[data-awake] {
  z-index: 1;
}
.empty {
  margin: 40px 0;
  color: var(--muted);
  font-family: var(--mono);
  font-size: 11px;
  letter-spacing: 0.12em;
  text-transform: uppercase;
}

/* A volume, seen from an eye --eye cover widths from its left edge and --depth widths away; it is
   --t widths thick. --k is how much nearer the eye its back is: the share the page edges show. */
.book {
  --w: 100cqw;
  --h: calc(var(--w) * var(--cover-ratio, 1.5625));
  --t: 0.085;
  --depth: 7;
  --eye: 3.4;
  --k: calc(var(--t) / (var(--depth) + var(--t)));
  position: relative;
  display: block;
  width: var(--w);
  height: var(--h);
  margin: 0;
  padding: 0;
  border: 0;
  outline: none;
  background: none;
  cursor: pointer;
  perspective: calc(var(--w) * var(--depth));
  perspective-origin: calc(var(--w) * var(--eye)) 50%;
}
.shadow {
  position: absolute;
  inset: 2% 0 0 3%;
  border-radius: 3px;
  box-shadow: -7px 9px 16px -5px var(--cast);
  transition:
    transform var(--spring-ms) var(--spring),
    opacity 0.3s ease;
}
.book[data-pose='tilt'] .shadow {
  opacity: 0.85;
  transform: translate(calc(var(--w) * -0.05), calc(var(--w) * 0.08)) scale(1.05);
  transition:
    transform 0.25s ease-out,
    opacity 0.3s ease;
}

.body {
  position: absolute;
  inset: 0;
  transform-origin: 50% 50% calc(var(--w) * var(--t) / -2);
  transition: transform var(--spring-ms) var(--spring);
}
.body[data-solid] {
  transform-style: preserve-3d;
}
.book[data-pose='tilt'] .body {
  transform: translateZ(calc(var(--w) * var(--lift))) rotateX(var(--rx, 0deg))
    rotateY(var(--ry, 0deg));
  transition: transform 0.22s cubic-bezier(0.2, 0.7, 0.3, 1);
  will-change: transform;
}

/* At rest a volume is flat: its page edges painted beside the cover, at the angle the eye sees. */
.body:not([data-solid])::after {
  content: '';
  position: absolute;
  top: 0;
  left: 100%;
  width: calc(var(--w) * (var(--eye) - 1) * var(--k));
  height: 100%;
  background: repeating-linear-gradient(90deg, #f7f3eb 0 0.8px, #d5cec1 0.8px 1.5px);
  clip-path: polygon(
    0 0,
    100% calc(var(--h) / 2 * var(--k)),
    100% calc(100% - var(--h) / 2 * var(--k)),
    0 100%
  );
}

/* The faces. The cover is at z = 0; the page edges fold back from its sides. */
.cover {
  position: absolute;
  inset: 0;
  overflow: hidden;
  border-radius: 1px 2px 2px 1px;
  background: #1a1a1a;
}
.art {
  display: block;
  width: 100%;
  height: 100%;
  object-fit: fill;
  user-select: none;
}
.caption {
  position: absolute;
  bottom: 0;
  left: 0;
  right: 0;
  padding: 16% 5% 5%;
  overflow: hidden;
  background: linear-gradient(transparent, rgb(0 0 0 / 0.78));
  color: #fff;
  font: 800 calc(var(--w) * 0.065) / 1.1 var(--display);
  text-align: left;
  text-overflow: ellipsis;
  text-transform: uppercase;
  white-space: nowrap;
  transition: opacity 0.2s ease;
}
.book[data-pose='tilt'] .caption {
  opacity: 0;
}
.edge,
.top,
.bottom {
  position: absolute;
  background: repeating-linear-gradient(90deg, #f7f3eb 0 0.8px, #d5cec1 0.8px 1.5px);
}
.edge {
  top: 0;
  left: 100%;
  width: calc(var(--w) * var(--t));
  height: 100%;
  transform-origin: 0 50%;
  transform: rotateY(90deg);
}
.top,
.bottom {
  left: 0;
  width: 100%;
  height: calc(var(--w) * var(--t));
  background: repeating-linear-gradient(to bottom, #f7f3eb 0 0.8px, #d5cec1 0.8px 1.5px);
}
.top {
  bottom: 100%;
  transform-origin: 50% 100%;
  transform: rotateX(90deg);
}
.bottom {
  top: 100%;
  transform-origin: 50% 0;
  transform: rotateX(-90deg);
}

/* On the cover: the rating, which stays, and what shows over the pointer. */
.rating,
.genre,
.chapter {
  position: absolute;
  padding: 0.42em 0.55em 0.36em;
  border-radius: 2px;
  font: 800 calc(var(--w) * 0.05) / 1 var(--mono);
  letter-spacing: 0.06em;
  white-space: nowrap;
}
.rating {
  top: 3.6%;
  right: 4.5%;
  background: #ffe100;
  color: #111;
}
.genre,
.chapter,
.track {
  bottom: 8.5%;
  opacity: 0;
  transition: opacity 0.2s ease;
}
.genre {
  left: 5%;
  box-shadow: inset 0 0 0 1px rgb(255 255 255 / 0.85);
  background: rgb(0 0 0 / 0.6);
  color: #fff;
  text-transform: uppercase;
}
.chapter {
  right: 5%;
  background: #e5261f;
  color: #fff;
}
.track {
  position: absolute;
  bottom: 4.2%;
  left: 5%;
  right: 5%;
  height: calc(var(--w) * 0.022);
  overflow: hidden;
  border-radius: 2px;
  background: rgb(255 255 255 / 0.32);
}
.trackFill {
  display: block;
  height: 100%;
  background: #33d3ec;
  transform-origin: 0 50%;
  transform: scaleX(var(--share));
}
.book[data-pose='tilt'] :is(.genre, .chapter, .track) {
  opacity: 1;
}

/* The focus ring and the open volume's mark are drawn on the cover, so they move with it. */
.book:focus-visible {
  outline: none;
}
.book:focus-visible .cover,
.book[data-open] .cover {
  outline: 2px solid var(--focus);
  outline-offset: 3px;
}

/* Flat covers: no page edges, no tilt. */
.app[data-depth='flat'] .body::after {
  display: none;
}
.app[data-depth='flat'] .book[data-pose='tilt'] .body {
  transform: none;
}

/* An actual two-page book. The closed front cover lies over the right page, and hinges left
   about the spine to expose its illustrated inside face and the reading details. */
.panel {
  position: absolute;
  container: open-book / inline-size;
  z-index: 3;
  top: calc(50% + var(--bar-h) / 2);
  left: 50%;
  width: min(
    480px,
    calc(100% - 28px),
    calc((100cqh - var(--bar-h) - 32px) * 2 / var(--open-ratio, 1.5625))
  );
  aspect-ratio: calc(2 / var(--open-ratio, 1.5625));
  transform: translate(-50%, -50%);
  perspective: 1100px;
  perspective-origin: 50% 50%;
  color: var(--ink);
}
.spread {
  position: absolute;
  inset: 0;
  transform-style: preserve-3d;
  transform-origin: 50% 0;
  will-change: transform;
}
.panelPage {
  position: absolute;
  inset: 0 0 0 50%;
  display: flex;
  flex-direction: column;
  gap: 12px;
  box-sizing: border-box;
  padding: 12px 14px;
  overflow: auto;
  border: 1px solid color-mix(in srgb, var(--ink) 28%, var(--panel));
  border-radius: 0 3px 3px 0;
  background: var(--panel);
  box-shadow:
    inset 9px 0 15px -12px var(--cast),
    5px 8px 14px -10px var(--cast);
}
.coverLeaf {
  position: absolute;
  inset: 0 0 0 50%;
  z-index: 2;
  transform-style: preserve-3d;
  transform-origin: 0 50%;
  transform: rotateY(-145deg);
  will-change: transform;
}
.leafFront,
.leafInside {
  position: absolute;
  inset: 0;
  overflow: hidden;
  box-sizing: border-box;
  backface-visibility: hidden;
  border: 1px solid var(--edge);
  border-radius: 2px;
}
.leafFront {
  transform: translateZ(1px);
  background: #111;
}
.leafInside {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 10px;
  padding: 9% 8%;
  transform: rotateY(180deg) translateZ(1px);
  background: var(--panel);
  box-shadow:
    inset -8px 0 18px -13px var(--cast),
    -4px 8px 14px -10px var(--cast);
}
.insideArt {
  width: 100%;
  aspect-ratio: calc(1 / var(--open-ratio, 1.5625));
  min-height: 0;
  overflow: hidden;
  box-shadow: 0 1px 4px var(--cast);
}
.insideArt .art {
  object-fit: contain;
}
.insideNative {
  margin: auto 0 0;
  font-size: 12px;
  font-weight: 700;
  text-align: center;
}
.insideEdition {
  font: 8px/1.4 var(--mono);
  color: var(--muted);
  text-transform: uppercase;
  text-align: center;
}
.book[data-open] .body,
.book[data-open] .shadow {
  opacity: 0;
}
.panelHead {
  display: flex;
  align-items: center;
  gap: 6px;
  font-family: var(--mono);
  font-size: 9px;
  font-weight: 700;
  letter-spacing: 0.12em;
  text-transform: uppercase;
}
.panelHead .round {
  margin-left: auto;
  flex: none;
  width: 32px;
  height: 32px;
}
.panelChip {
  padding: 4px 6px 3px;
  border-radius: 3px;
  box-shadow: inset 0 0 0 1px var(--ink);
  font-size: 8px;
}
.panelMain {
  display: flex;
  gap: 14px;
}
.panelText {
  display: flex;
  flex-direction: column;
  gap: 6px;
  min-width: 0;
}
.panelTitle {
  margin: 0;
  font-family: var(--display);
  font-size: 22px;
  font-stretch: condensed;
  font-weight: 800;
  letter-spacing: 0.02em;
  line-height: 0.95;
  text-transform: uppercase;
}
.panelNative {
  margin: 0;
  font-size: 13px;
  font-weight: 700;
}
.panelText .muted {
  font-family: var(--mono);
  font-size: 9px;
  letter-spacing: 0.06em;
  text-transform: uppercase;
}
.panelRating {
  align-self: flex-start;
  margin: 2px 0 0;
  padding: 4px 6px 3px;
  border-radius: 2px;
  background: #ffe100;
  color: #111;
  font: 800 10px/1 var(--mono);
}
.panelProgress {
  display: flex;
  flex-direction: column;
  gap: 8px;
}
.panelLine {
  display: flex;
  justify-content: space-between;
  margin: 0;
  font-family: var(--mono);
  font-size: 10.5px;
  font-weight: 700;
  letter-spacing: 0.08em;
  text-transform: uppercase;
}
.panelTrack {
  height: 5px;
  overflow: hidden;
  border-radius: 3px;
  background: var(--pill);
}
.panelFill {
  display: block;
  height: 100%;
  background: #33d3ec;
  transform-origin: 0 50%;
  transform: scaleX(var(--share));
  transition: transform 0.45s var(--spring);
}
.panelChapters {
  display: flex;
  flex-direction: column;
  gap: 8px;
}
.panelChapters .muted {
  font-family: var(--mono);
  font-size: 10px;
  font-weight: 700;
  letter-spacing: 0.12em;
  text-transform: uppercase;
}
.chapters {
  display: grid;
  grid-template-columns: repeat(auto-fill, 10px);
  gap: 4px;
  margin: 0;
  padding: 0;
  list-style: none;
}
.chapters li {
  height: 10px;
  border-radius: 2px;
  background: var(--pill);
}
.chapters li[data-state='read'] {
  background: var(--ink);
}
.chapters li[data-state='next'] {
  box-shadow: inset 0 0 0 1.5px #e5261f;
}
.continue {
  height: 40px;
  margin-top: auto;
  flex: none;
  border: 0;
  border-radius: 10px;
  background: var(--ink);
  color: var(--panel);
  font-size: 11px;
  cursor: pointer;
}
.continue[aria-disabled='true'] {
  opacity: 0.45;
  cursor: default;
}
@container open-book (max-width: 420px) {
  .panelPage {
    padding: 8px;
    gap: 8px;
  }
  .panelHead {
    gap: 4px;
    letter-spacing: 0.04em;
  }
  .panelChip {
    padding: 3px;
    font-size: 7px;
  }
  .panelTitle {
    font-size: 18px;
  }
  .panelNative {
    font-size: 11px;
  }
  .panelText .muted {
    font-size: 8px;
  }
  .panelLine {
    font-size: 8px;
    letter-spacing: 0.02em;
  }
  .chapters {
    grid-template-columns: repeat(auto-fill, 7px);
    gap: 3px;
  }
  .chapters li {
    height: 7px;
  }
  .insideEdition {
    font-size: 7px;
  }
  .continue {
    font-size: 9px;
  }
}

.round:focus-visible,
.pill:focus-visible,
.tab:focus-visible,
.continue:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 2px;
}

/* For screen readers only. */
.hidden {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  padding: 0;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

/* Narrower windows: the sorting goes, then the bar takes two rows, then the grid three columns. */
@container app (max-width: 700px) {
  .bar:has(.search) .sort {
    display: none;
  }
}
@container app (max-width: 620px) {
  .sort {
    display: none;
  }
}
@container app (max-width: 560px) {
  .bar {
    flex-wrap: wrap;
    align-content: center;
    row-gap: 8px;
    padding: 0 10px 0 14px;
  }
  .brand {
    order: 1;
    margin-right: auto;
  }
  .tools {
    display: contents;
  }
  .round {
    order: 2;
  }
  .showing {
    order: 3;
    margin-right: auto;
  }
  .pill,
  .search {
    order: 4;
  }
  .content {
    gap: 28px;
    padding: 20px 14px 24px;
  }
  .group {
    grid-template-columns: 22px minmax(0, 1fr);
    gap: 12px;
  }
  .tab {
    width: 22px;
  }
  .grid {
    gap: 16px 12px;
  }
  .search {
    width: 120px;
  }
}
@container app (max-width: 420px) {
  .showing {
    flex: 0 0 100px;
    box-sizing: border-box;
    gap: 5px;
    padding: 0 8px;
  }
  .showing .muted {
    display: none;
  }
  .showing > span:nth-of-type(2) {
    flex: 1;
    min-width: 0;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
  }
  .showing :is(.count, .chevron) {
    flex: none;
  }
  .search {
    flex: 1;
    min-width: 0;
    width: 0;
  }
  .grid {
    grid-template-columns: repeat(3, minmax(0, 1fr));
  }
}
@container stage (max-width: 560px) {
  .app {
    --bar-h: 88px;
  }
}

@media (prefers-reduced-motion: reduce) {
  .book[data-pose='tilt'] .body,
  .book[data-pose='tilt'] .shadow {
    transform: none;
  }
}
```

### book-shelf/book.tsx

```tsx
import type { CSSProperties } from 'react';
import type { Book } from './books';
import { FrontCover } from './covers';
import styles from './book-shelf.module.css';

/*
 * A volume as an object, in CSS 3D. The cover is at z = 0 and the pages reach back from it; the
 * eye is to the right of the book, so the block of page edges shows along its right side.
 *
 * - At rest a book is flat: its cover, and its page edges painted beside it by a clipped
 *   pseudo-element at the very angle the 3D face shows from the eye. Only a book that moves is
 *   solid (`solid`): the page edges and the thin top and bottom edges are then real faces.
 * - The cover carries the hover overlays: a genre chip, a chapter chip, a progress bar, and the
 *   rating badge, which stays on.
 */

export function Volume({
  book,
  read,
  solid,
  id,
}: {
  book: Book;
  /** Chapters read. */
  read: number;
  solid: boolean;
  /** A unique id for the cover. */
  id: string;
}) {
  return (
    <span className={styles.body} data-solid={solid || undefined}>
      <span className={styles.cover} data-cover="" data-part="cover">
        <FrontCover book={book} id={id} className={styles.art} />
        <span className={styles.caption}>{book.title}</span>
        {book.rating !== undefined && (
          <span className={styles.rating} data-part="rating">
            ★ {book.rating}
          </span>
        )}
        <span className={styles.genre} data-part="genre">
          {book.genre}
        </span>
        <span className={styles.chapter} data-part="chapter">
          CH {read} / {book.chapters}
        </span>
        <span className={styles.track} data-part="track">
          <span
            className={styles.trackFill}
            style={{ '--share': read / book.chapters } as CSSProperties}
          />
        </span>
      </span>
      {solid && (
        <>
          <span className={styles.edge} />
          <span className={styles.top} />
          <span className={styles.bottom} />
        </>
      )}
    </span>
  );
}
```

### book-shelf/covers.tsx

```tsx
import type { Book } from './books';

/** The actual Japanese cover, reused by the volume and both faces of its hinged cover. */
export function FrontCover({
  book,
  id,
  className,
}: {
  book: Book;
  id: string;
  className?: string;
}) {
  return (
    <img
      id={id}
      src={book.cover}
      alt=""
      aria-hidden="true"
      className={className}
      width={500}
      height={Math.round(500 * (book.coverRatio ?? 1.5625))}
      draggable={false}
      loading="lazy"
      decoding="async"
    />
  );
}
```

### book-shelf/books.ts

```tsx
/*
 * 36 Japanese editions: volumes 1–9 of Naruto, Hajime no Ippo, Bleach and Bakuman.
 * Cover artwork and publication years are sourced from the official Shueisha and Kodansha
 * catalogs below. Artwork belongs to the authors and publishers. Chapter counts, progress and
 * personal ratings are illustrative demo state, not bibliographic metadata.
 */

export type Status = 'reading' | 'plan' | 'done';

export type Book = {
  id: string;
  title: string;
  native: string;
  author: string;
  volume: number;
  genre: string;
  year: number;
  chapter: number;
  chapters: number;
  rating?: number;
  /** A local or remote cover image, with its original height / width ratio. */
  cover: string;
  coverRatio?: number;
  publisher?: string;
  source?: string;
};

export const STATUSES: { id: Status; label: string }[] = [
  { id: 'reading', label: 'Reading' },
  { id: 'plan', label: 'Plan to read' },
  { id: 'done', label: 'Completed' },
];

const SERIES = {
  naruto: {
    title: 'Naruto',
    native: 'ナルト',
    author: 'Masashi Kishimoto',
    genre: 'Action',
    publisher: 'Shueisha · Jump Comics',
  },
  ippo: {
    title: 'Hajime no Ippo',
    native: 'はじめの一歩',
    author: 'George Morikawa',
    genre: 'Sports',
    publisher: 'Kodansha · Shonen Magazine Comics',
  },
  bleach: {
    title: 'Bleach',
    native: 'ブリーチ',
    author: 'Tite Kubo',
    genre: 'Fantasy',
    publisher: 'Shueisha · Jump Comics',
  },
  bakuman: {
    title: 'Bakuman',
    native: 'バクマン。',
    author: 'Tsugumi Ohba · Takeshi Obata',
    genre: 'Drama',
    publisher: 'Shueisha · Jump Comics',
  },
} as const;

/** Publisher metadata, recorded with each cover so that its edition remains traceable. */
const EDITIONS = [
  {
    id: 'naruto-1',
    series: 'naruto',
    volume: 1,
    year: 2000,
    ratio: 1.5625,
    source: 'https://www.shueisha.co.jp/books/items/contents.html?isbn=4-08-872840-8',
  },
  {
    id: 'ippo-1',
    series: 'ippo',
    volume: 1,
    year: 1990,
    ratio: 1.57813,
    source: 'https://shonenmagazine.com/special_page/hajimenoippo/',
  },
  {
    id: 'bleach-1',
    series: 'bleach',
    volume: 1,
    year: 2002,
    ratio: 1.58228,
    source: 'https://www.shueisha.co.jp/books/items/contents.html?isbn=4-08-873213-8',
  },
  {
    id: 'bakuman-1',
    series: 'bakuman',
    volume: 1,
    year: 2009,
    ratio: 1.5528,
    source: 'https://www.shueisha.co.jp/books/items/contents.html?isbn=9784088746227',
  },
  {
    id: 'naruto-2',
    series: 'naruto',
    volume: 2,
    year: 2000,
    ratio: 1.57729,
    source: 'https://www.shueisha.co.jp/books/items/contents.html?isbn=4-08-872878-5',
  },
  {
    id: 'ippo-2',
    series: 'ippo',
    volume: 2,
    year: 1990,
    ratio: 1.57813,
    source: 'https://shonenmagazine.com/special_page/hajimenoippo/',
  },
  {
    id: 'bleach-2',
    series: 'bleach',
    volume: 2,
    year: 2002,
    ratio: 1.5873,
    source: 'https://www.shueisha.co.jp/books/items/contents.html?isbn=4-08-873237-5',
  },
  {
    id: 'bakuman-2',
    series: 'bakuman',
    volume: 2,
    year: 2009,
    ratio: 1.57233,
    source: 'https://www.shueisha.co.jp/books/items/contents.html?isbn=978-4-08-874644-9',
  },
  {
    id: 'naruto-3',
    series: 'naruto',
    volume: 3,
    year: 2000,
    ratio: 1.57729,
    source: 'https://www.shueisha.co.jp/books/items/contents.html?isbn=4-08-872898-X',
  },
  {
    id: 'ippo-3',
    series: 'ippo',
    volume: 3,
    year: 1990,
    ratio: 1.57813,
    source: 'https://shonenmagazine.com/special_page/hajimenoippo/',
  },
  {
    id: 'bleach-3',
    series: 'bleach',
    volume: 3,
    year: 2002,
    ratio: 1.5873,
    source: 'https://www.shueisha.co.jp/books/items/contents.html?isbn=4-08-873275-8',
  },
  {
    id: 'bakuman-3',
    series: 'bakuman',
    volume: 3,
    year: 2009,
    ratio: 1.57233,
    source: 'https://www.shueisha.co.jp/books/items/contents.html?isbn=978-4-08-874677-7',
  },
  {
    id: 'naruto-4',
    series: 'naruto',
    volume: 4,
    year: 2000,
    ratio: 1.5873,
    source: 'https://www.shueisha.co.jp/books/items/contents.html?isbn=4-08-873026-7',
  },
  {
    id: 'ippo-4',
    series: 'ippo',
    volume: 4,
    year: 1990,
    ratio: 1.57813,
    source: 'https://shonenmagazine.com/special_page/hajimenoippo/',
  },
  {
    id: 'bleach-4',
    series: 'bleach',
    volume: 4,
    year: 2002,
    ratio: 1.57729,
    source: 'https://www.shueisha.co.jp/books/items/contents.html?isbn=4-08-873310-X',
  },
  {
    id: 'bakuman-4',
    series: 'bakuman',
    volume: 4,
    year: 2009,
    ratio: 1.57233,
    source: 'https://www.shueisha.co.jp/books/items/contents.html?isbn=978-4-08-874719-4',
  },
  {
    id: 'naruto-5',
    series: 'naruto',
    volume: 5,
    year: 2000,
    ratio: 1.57729,
    source: 'https://www.shueisha.co.jp/books/items/contents.html?isbn=4-08-873050-X',
  },
  {
    id: 'ippo-5',
    series: 'ippo',
    volume: 5,
    year: 1990,
    ratio: 1.57813,
    source: 'https://shonenmagazine.com/special_page/hajimenoippo/',
  },
  {
    id: 'bleach-5',
    series: 'bleach',
    volume: 5,
    year: 2002,
    ratio: 1.5873,
    source: 'https://www.shueisha.co.jp/books/items/contents.html?isbn=4-08-873335-5',
  },
  {
    id: 'bakuman-5',
    series: 'bakuman',
    volume: 5,
    year: 2009,
    ratio: 1.57233,
    source: 'https://www.shueisha.co.jp/books/items/contents.html?isbn=978-4-08-874753-8',
  },
  {
    id: 'naruto-6',
    series: 'naruto',
    volume: 6,
    year: 2001,
    ratio: 1.5873,
    source: 'https://www.shueisha.co.jp/books/items/contents.html?isbn=4-08-873089-5',
  },
  {
    id: 'ippo-6',
    series: 'ippo',
    volume: 6,
    year: 1990,
    ratio: 1.57813,
    source: 'https://shonenmagazine.com/special_page/hajimenoippo/',
  },
  {
    id: 'bleach-6',
    series: 'bleach',
    volume: 6,
    year: 2002,
    ratio: 1.59744,
    source: 'https://www.shueisha.co.jp/books/items/contents.html?isbn=4-08-873366-5',
  },
  {
    id: 'bakuman-6',
    series: 'bakuman',
    volume: 6,
    year: 2010,
    ratio: 1.57233,
    source: 'https://www.shueisha.co.jp/books/items/contents.html?isbn=978-4-08-874788-0',
  },
  {
    id: 'naruto-7',
    series: 'naruto',
    volume: 7,
    year: 2001,
    ratio: 1.57729,
    source: 'https://www.shueisha.co.jp/books/items/contents.html?isbn=4-08-873113-1',
  },
  {
    id: 'ippo-7',
    series: 'ippo',
    volume: 7,
    year: 1991,
    ratio: 1.57813,
    source: 'https://shonenmagazine.com/special_page/hajimenoippo/',
  },
  {
    id: 'bleach-7',
    series: 'bleach',
    volume: 7,
    year: 2003,
    ratio: 1.5873,
    source: 'https://www.shueisha.co.jp/books/items/contents.html?isbn=4-08-873392-4',
  },
  {
    id: 'bakuman-7',
    series: 'bakuman',
    volume: 7,
    year: 2010,
    ratio: 1.5674,
    source: 'https://www.shueisha.co.jp/books/items/contents.html?isbn=978-4-08-870015-1',
  },
  {
    id: 'naruto-8',
    series: 'naruto',
    volume: 8,
    year: 2001,
    ratio: 1.58228,
    source: 'https://www.shueisha.co.jp/books/items/contents.html?isbn=4-08-873147-6',
  },
  {
    id: 'ippo-8',
    series: 'ippo',
    volume: 8,
    year: 1991,
    ratio: 1.57813,
    source: 'https://shonenmagazine.com/special_page/hajimenoippo/',
  },
  {
    id: 'bleach-8',
    series: 'bleach',
    volume: 8,
    year: 2003,
    ratio: 1.55763,
    source: 'https://www.shueisha.co.jp/books/items/contents.html?isbn=4-08-873435-1',
  },
  {
    id: 'bakuman-8',
    series: 'bakuman',
    volume: 8,
    year: 2010,
    ratio: 1.57729,
    source: 'https://www.shueisha.co.jp/books/items/contents.html?isbn=978-4-08-870037-3',
  },
  {
    id: 'naruto-9',
    series: 'naruto',
    volume: 9,
    year: 2001,
    ratio: 1.57233,
    source: 'https://www.shueisha.co.jp/books/items/contents.html?isbn=4-08-873174-3',
  },
  {
    id: 'ippo-9',
    series: 'ippo',
    volume: 9,
    year: 1991,
    ratio: 1.57813,
    source: 'https://shonenmagazine.com/special_page/hajimenoippo/',
  },
  {
    id: 'bleach-9',
    series: 'bleach',
    volume: 9,
    year: 2003,
    ratio: 1.5873,
    source: 'https://www.shueisha.co.jp/books/items/contents.html?isbn=4-08-873495-5',
  },
  {
    id: 'bakuman-9',
    series: 'bakuman',
    volume: 9,
    year: 2010,
    ratio: 1.5625,
    source: 'https://www.shueisha.co.jp/books/items/contents.html?isbn=978-4-08-870088-5',
  },
] as const;

export const BOOKS: Book[] = EDITIONS.map((edition, index) => ({
  ...SERIES[edition.series],
  id: edition.id,
  volume: edition.volume,
  year: edition.year,
  cover: `/experiments/book-shelf/${edition.id}.webp`,
  coverRatio: edition.ratio,
  source: edition.source,
  // Reading state only: 25 volumes started, the last 11 planned next.
  chapters: 12,
  chapter: index < 25 ? 3 + (index % 8) : 0,
  rating: index % 3 === 0 ? 9 : undefined,
}));
```

### book-shelf/icons.tsx

```tsx
/* The app's icons, drawn on a 16 × 16 grid in the current colour. All decorative. */

type IconProps = { className?: string };

/** The light theme's eye: closed. */
export function EyeClosedIcon({ className }: IconProps) {
  return (
    <svg className={className} viewBox="0 0 16 16" aria-hidden="true">
      <path
        d="M2.5 7.5c3 3.4 8 3.4 11 0M5 10l-1 1.6M8 10.8v1.8M11 10l1 1.6"
        fill="none"
        stroke="currentColor"
        strokeWidth="1.4"
        strokeLinecap="round"
      />
    </svg>
  );
}

/** The dark theme's eye: open, with a diamond for a pupil. */
export function EyeOpenIcon({ className }: IconProps) {
  return (
    <svg className={className} viewBox="0 0 16 16" aria-hidden="true">
      <path
        d="M1.8 8c3.4-4.4 9-4.4 12.4 0-3.4 4.4-9 4.4-12.4 0Z"
        fill="none"
        stroke="currentColor"
        strokeWidth="1.4"
        strokeLinejoin="round"
      />
      <path d="M8 5.6 10.4 8 8 10.4 5.6 8Z" fill="currentColor" />
    </svg>
  );
}

/** A cube, for books drawn in 3D. */
export function CubeIcon({ className }: IconProps) {
  return (
    <svg className={className} viewBox="0 0 16 16" aria-hidden="true">
      <path
        d="M8 1.8 13.4 5v6L8 14.2 2.6 11V5ZM2.6 5 8 8.2 13.4 5M8 8.2v6"
        fill="none"
        stroke="currentColor"
        strokeWidth="1.3"
        strokeLinejoin="round"
      />
    </svg>
  );
}

export function ChevronIcon({ className }: IconProps) {
  return (
    <svg className={className} viewBox="0 0 16 16" aria-hidden="true">
      <path
        d="m5 6.5 3 3 3-3"
        fill="none"
        stroke="currentColor"
        strokeWidth="1.6"
        strokeLinecap="round"
        strokeLinejoin="round"
      />
    </svg>
  );
}

export function CloseIcon({ className }: IconProps) {
  return (
    <svg className={className} viewBox="0 0 16 16" aria-hidden="true">
      <path
        d="m4.5 4.5 7 7m0-7-7 7"
        stroke="currentColor"
        strokeWidth="1.6"
        strokeLinecap="round"
      />
    </svg>
  );
}
```

### book-shelf/spring.ts

```tsx
/*
 * Springs as CSS easings. A spring pulls a value to its target and lets it settle: fast at first,
 * then slowing down, overshooting a little and coming back if it is bouncy. CSS `linear()` draws
 * any curve through a list of points, so the spring's motion is sampled once, and the same curve
 * serves CSS transitions and the Web Animations API.
 */

export type Spring = {
  /** The curve, as a CSS `linear()` easing. */
  easing: string;
  /** How long it takes to settle, in ms: the duration to play the curve over. */
  duration: number;
};

/** Points sampled along the curve: plenty for a smooth motion. */
const SAMPLES = 40;

/**
 * A spring that covers most of the distance in about `response` ms. With a `bounce` of 0 it
 * settles without overshooting; at 0.3 it overshoots by about 5% and comes back.
 */
export function spring(bounce: number, response: number): Spring {
  // The damping ratio (1: no overshoot) and the natural frequency, in radians per second.
  const damping = 1 - bounce;
  const omega = (2 * Math.PI * 1000) / response;
  const position = (t: number) => {
    if (damping >= 1) return 1 - Math.exp(-omega * t) * (1 + omega * t);
    const wobble = omega * Math.sqrt(1 - damping * damping);
    const decay = Math.exp(-damping * omega * t);
    return 1 - decay * (Math.cos(wobble * t) + ((damping * omega) / wobble) * Math.sin(wobble * t));
  };
  // Settled: from then on, within 0.1% of the target.
  let settled = 0;
  for (let t = 0; t < 3; t += 0.002) if (Math.abs(1 - position(t)) > 0.001) settled = t;
  const points = Array.from({ length: SAMPLES + 1 }, (_, i) =>
    i === SAMPLES ? 1 : Math.round(position((i / SAMPLES) * settled) * 1e4) / 1e4,
  );
  return { easing: `linear(${points.join(', ')})`, duration: Math.round(settled * 1000) };
}

/** The spring's easing for `element.animate()`, or an ease-out where `linear()` is unknown. */
export function easingOf({ easing }: Spring) {
  return CSS.supports('transition-timing-function', easing)
    ? easing
    : 'cubic-bezier(0.2, 0.8, 0.2, 1)';
}
```

### book-shelf/ghost-pointer.tsx

```tsx
'use client';

import type { CSSProperties, Ref } from 'react';

/*
 * The demo's ghost pointer, and the timeline that moves it. A demo is a script of awaited steps:
 * wait, glide the pointer onto a book, trace a path across it while the book tilts after it,
 * press. Stopping the demo (the reader takes over, or it scrolls away) ends the step in progress
 * at once: its promise rejects, which ends the script right there, and the pointer fades out.
 */

type Point = { x: number; y: number };

export type Demo = {
  /** Resolves after `ms`. */
  wait: (ms: number) => Promise<void>;
  /** Glides the pointer onto `target`: its middle, or a point given as shares of its size. */
  moveTo: (target: Element | null | undefined, at?: Point) => Promise<void>;
  /**
   * Traces a path across `target`, through points given as shares of its size, in `ms`;
   * `onPoint` gets the point under the pointer on every frame.
   */
  trace: (
    target: Element | null | undefined,
    path: Point[],
    ms: number,
    onPoint: (point: Point) => void,
  ) => Promise<void>;
  /** Presses and lets go; `action` runs as the pointer goes down. */
  press: (action: () => void) => Promise<void>;
  /** Moves the pointer off, down and to the right, and fades it out. */
  leave: () => Promise<void>;
};

/** What a stopped demo's pending steps reject with. */
class Stopped extends Error {}

/** Where the arrow's tip is, in its 20 × 20 box. */
const TIP: Point = { x: 3, y: 2 };

const place = ({ x, y }: Point) => `translate(${x - TIP.x}px, ${y - TIP.y}px)`;

/** Slow at both ends, as a hand moves. */
const easeInOut = (k: number) => (k < 0.5 ? 4 * k * k * k : 1 - (-2 * k + 2) ** 3 / 2);

/** Plays `script` with the pointer over `stage`; returns the function that stops it. */
export function runDemo(
  stage: HTMLElement,
  pointer: SVGSVGElement,
  script: (demo: Demo) => Promise<unknown>,
): () => void {
  let alive = true;
  let flight: Animation | null = null;
  const timers = new Set<number>();
  const frames = new Set<number>();
  const pending = new Set<() => void>();
  // The pointer comes in from the lower right.
  let at: Point = { x: stage.offsetWidth * 0.86, y: stage.offsetHeight * 0.92 };

  /** Settles like `promise`, or rejects as soon as the demo stops. */
  const step = (promise: Promise<unknown>) =>
    new Promise<void>((resolve, reject) => {
      if (!alive) return reject(new Stopped());
      const cancel = () => reject(new Stopped());
      pending.add(cancel);
      promise.then(
        () => {
          pending.delete(cancel);
          if (alive) resolve();
        },
        () => {
          pending.delete(cancel);
          reject(new Stopped());
        },
      );
    });

  const wait = (ms: number) =>
    step(
      new Promise<void>((resolve) => {
        const timer = window.setTimeout(() => {
          timers.delete(timer);
          resolve();
        }, ms);
        timers.add(timer);
      }),
    );

  /** A point on the screen, in the stage's own px (it may sit in a scaled container). */
  const toStage = (x: number, y: number): Point => {
    const frame = stage.getBoundingClientRect();
    const scale = frame.width / stage.offsetWidth || 1;
    return { x: (x - frame.left) / scale, y: (y - frame.top) / scale };
  };

  /** Calls `onFrame` with the eased progress, 0 to 1, on every frame for `ms`. */
  const tween = (ms: number, onFrame: (k: number) => void) =>
    step(
      new Promise<void>((resolve) => {
        const start = performance.now();
        const tick = (now: number) => {
          frames.delete(frame);
          const k = Math.min(1, Math.max(0, (now - start) / ms));
          onFrame(easeInOut(k));
          if (k < 1) {
            frame = requestAnimationFrame(tick);
            frames.add(frame);
          } else resolve();
        };
        let frame = requestAnimationFrame(tick);
        frames.add(frame);
      }),
    );

  const fly = (to: Point, ms: number) => {
    const from = at;
    at = to;
    const dx = to.x - from.x;
    const dy = to.y - from.y;
    // A gentle curve, as a hand moves: the control point sits off the straight line.
    const bend = { x: (from.x + to.x) / 2 - dy * 0.16, y: (from.y + to.y) / 2 + dx * 0.16 };
    const keyframes: Keyframe[] = [];
    for (let i = 0; i <= 12; i += 1) {
      const t = i / 12;
      const u = 1 - t;
      keyframes.push({
        transform: place({
          x: u * u * from.x + 2 * u * t * bend.x + t * t * to.x,
          y: u * u * from.y + 2 * u * t * bend.y + t * t * to.y,
        }),
      });
    }
    flight?.cancel();
    // Where it rests once the flight is over.
    pointer.style.transform = place(to);
    flight = pointer.animate(keyframes, {
      duration: ms || Math.min(950, Math.max(360, Math.hypot(dx, dy) * 1.6)),
      easing: 'cubic-bezier(0.45, 0, 0.2, 1)',
    });
    pointer.style.opacity = '1';
    return step(flight.finished);
  };

  const moveTo: Demo['moveTo'] = (target, share = { x: 0.5, y: 0.5 }) => {
    if (!target) return step(Promise.resolve());
    const box = target.getBoundingClientRect();
    return fly(toStage(box.left + box.width * share.x, box.top + box.height * share.y), 0);
  };

  const trace: Demo['trace'] = (target, path, ms, onPoint) => {
    if (!target || path.length < 2) return step(Promise.resolve());
    flight?.cancel();
    const legs = path.length - 1;
    return tween(ms, (k) => {
      // Along the path, leg after leg; measured on every frame, as the page may scroll.
      const along = k * legs;
      const leg = Math.min(legs - 1, Math.floor(along));
      const [a, b] = [path[leg], path[leg + 1]];
      const t = along - leg;
      const point = { x: a.x + (b.x - a.x) * t, y: a.y + (b.y - a.y) * t };
      const box = target.getBoundingClientRect();
      at = toStage(box.left + box.width * point.x, box.top + box.height * point.y);
      pointer.style.transform = place(at);
      onPoint(point);
    });
  };

  const press: Demo['press'] = async (action) => {
    const arrow = pointer.firstElementChild;
    const tap = arrow?.animate(
      [{ transform: 'none' }, { transform: 'scale(0.8)', offset: 0.35 }, { transform: 'none' }],
      { duration: 280, easing: 'ease-in-out' },
    );
    await wait(95);
    action();
    if (tap) await step(tap.finished);
  };

  const leave: Demo['leave'] = async () => {
    const flying = fly({ x: at.x + 40, y: at.y + 56 }, 520);
    pointer.style.opacity = '0';
    await flying;
  };

  pointer.style.transform = place(at);
  script({ wait, moveTo, trace, press, leave }).catch((error: unknown) => {
    if (!(error instanceof Stopped)) throw error;
  });

  return () => {
    if (!alive) return;
    alive = false;
    timers.forEach((timer) => window.clearTimeout(timer));
    frames.forEach((frame) => cancelAnimationFrame(frame));
    flight?.cancel();
    pending.forEach((cancel) => cancel());
    pointer.style.opacity = '0';
  };
}

const POINTER: CSSProperties = {
  position: 'absolute',
  top: 0,
  left: 0,
  zIndex: 10,
  width: 22,
  height: 22,
  overflow: 'visible',
  pointerEvents: 'none',
  opacity: 0,
  transition: 'opacity 0.3s ease',
  fill: '#1c1c1e',
  stroke: '#fff',
  strokeWidth: 1.3,
  strokeLinejoin: 'round',
  filter: 'drop-shadow(0 1px 1.5px rgb(0 0 0 / 0.25))',
};

/** The arrow the demo moves around. It is decorative: the demo stays silent. */
export function GhostPointer({ ref }: { ref: Ref<SVGSVGElement> }) {
  return (
    <svg ref={ref} viewBox="0 0 20 20" style={POINTER} aria-hidden="true">
      <path
        d="M3 2v14.5l3.6-3.3 2.6 5.6 2.4-1.1-2.6-5.5h5.2z"
        style={{ transformBox: 'fill-box', transformOrigin: '0 0' }}
      />
    </svg>
  );
}
```

### book-shelf/use-autoplay.ts

```tsx
'use client';

import { useCallback, useEffect, useRef, useState } from 'react';

/*
 * When a demo may play on its own: while part of it is on screen and the tab is visible, never
 * under reduced motion, and not while the reader is using it. `take()` hands it to the reader; it
 * takes back over `idle` ms after their last input.
 */
export function useAutoplay<T extends HTMLElement>(idle = 6000) {
  const ref = useRef<T>(null);
  const [visible, setVisible] = useState(false);
  const [reduced, setReduced] = useState(true);
  const [taken, setTaken] = useState(false);
  const timer = useRef(0);

  useEffect(() => {
    const element = ref.current;
    if (!element) return;
    let onScreen = false;
    const update = () => setVisible(onScreen && !document.hidden);
    const observer = new IntersectionObserver(
      ([entry]) => {
        onScreen = entry.isIntersecting;
        update();
      },
      { threshold: 0.3 },
    );
    observer.observe(element);
    document.addEventListener('visibilitychange', update);
    const motion = window.matchMedia('(prefers-reduced-motion: reduce)');
    const readMotion = () => setReduced(motion.matches);
    readMotion();
    motion.addEventListener('change', readMotion);
    return () => {
      observer.disconnect();
      document.removeEventListener('visibilitychange', update);
      motion.removeEventListener('change', readMotion);
    };
  }, []);

  const take = useCallback(() => {
    setTaken(true);
    window.clearTimeout(timer.current);
    timer.current = window.setTimeout(() => setTaken(false), idle);
  }, [idle]);
  useEffect(() => () => window.clearTimeout(timer.current), []);

  return { ref, take, playing: visible && !reduced && !taken };
}
```