Bruno DelorenceBruno
Appearance
Appearance

Choose your reading theme.

Contact

Book shelf

New

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

book-shelf/book-shelf.tsx
Live · interactive

Library

  • Action. Chapter 3 of 12. Rated 9 out of 10.
  • Sports. Chapter 4 of 12.
  • Fantasy. Chapter 5 of 12.
  • Drama. Chapter 6 of 12. Rated 9 out of 10.
  • Action. Chapter 7 of 12.
  • Sports. Chapter 8 of 12.
  • Fantasy. Chapter 9 of 12. Rated 9 out of 10.
  • Drama. Chapter 10 of 12.
  • Action. Chapter 3 of 12.
  • Sports. Chapter 4 of 12. Rated 9 out of 10.
  • Fantasy. Chapter 5 of 12.
  • Drama. Chapter 6 of 12.
  • Action. Chapter 7 of 12. Rated 9 out of 10.
  • Sports. Chapter 8 of 12.
  • Fantasy. Chapter 9 of 12.
  • Drama. Chapter 10 of 12. Rated 9 out of 10.
  • Action. Chapter 3 of 12.
  • Sports. Chapter 4 of 12.
  • Fantasy. Chapter 5 of 12. Rated 9 out of 10.
  • Drama. Chapter 6 of 12.
  • Action. Chapter 7 of 12.
  • Sports. Chapter 8 of 12. Rated 9 out of 10.
  • Fantasy. Chapter 9 of 12.
  • Drama. Chapter 10 of 12.
  • Action. Chapter 3 of 12. Rated 9 out of 10.
  • Sports. Chapter 0 of 12.
  • Fantasy. Chapter 0 of 12.
  • Drama. Chapter 0 of 12. Rated 9 out of 10.
  • Action. Chapter 0 of 12.
  • Sports. Chapter 0 of 12.
  • Fantasy. Chapter 0 of 12. Rated 9 out of 10.
  • Drama. Chapter 0 of 12.
  • Action. Chapter 0 of 12.
  • Sports. Chapter 0 of 12. Rated 9 out of 10.
  • Fantasy. Chapter 0 of 12.
  • Drama. Chapter 0 of 12.

Arrow keys move between volumes; Enter opens one.

The live component, numbered part by part. Every measurement is read from it as it is drawn. Point at a row to outline its part, or at a part to find its row.

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.

You can add this component with the shadcn CLI or by hand:

Into components/book-shelf of any React project, with no setup:

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

Either way it needs React 19.2 and a bundler that supports CSS Modules, such as Next.js or Vite. It has a dark colour scheme: 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.

Usage

reading-list.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} />;}

Each state below is a live instance, locked so you can study it.

The pieces it is built from, each with its measurements read live.

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.

  • 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.
PropTypeDefaultDescription
booksreadonly Book[]BOOKSThe 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.
namestring'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.
defaultStateBookShelfState—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.
autoplaybooleantrueWhile 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.
classNamestring—Extra class for the scrollable library surface.

The complete source behind this demo. Open source under the MIT license, © 2026 Bruno Delorence: copy it, change it, ship it.

book-shelf.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.module.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;
book.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. */
covers.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"
books.ts
/* * 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;
icons.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
spring.ts
/* * 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;
ghost-pointer.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,
use-autoplay.ts
'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;
  • 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.