A shelf of book spines. Click one and it slides out into a wide card, pushing the others aside. Add or take books off the shelf.

reacttypescriptcss
3 books

click or tap a book to pull it out

On phones and narrow screens, the shelf shrinks to fit the width; tap a book to pull it out. With the keyboard, every spine is a button: Tab to it and press Enter, then Escape or × puts it back. If your device is set to reduce motion, new books appear without dropping in.

The idea

A reading list that feels like a shelf, not a table. Every book gets its own spine, and pulling one out tells you a bit more about it. The first three are the books from my video, Three books to get out of a creative rut.

Words you'll see 15
component
A reusable piece of interface, like a component in Figma. BookStack is one.
props
The settings you hand to a component, like books or openWidth. Change them and the shelf changes.
array
A list of things, written between [ and ]. The shelf's books are an array.
map
Goes through a list and makes one thing for each item, here one book for each entry.
state (useState)
Something a component remembers, like which book is pulled out. When it changes, what you see updates.
useRef
A way to keep hold of a page element or a value without redrawing anything, like each book's spine.
useEffect
Code that runs after the component appears or changes, to connect it to something outside it, like listening for the Escape key.
flexbox
A way to lay things out in a row or column, like auto layout in Figma.
writing-mode
Which way text runs. vertical-rl turns it on its side, like a book spine.
transform-origin
The point something turns around, like moving the pivot point in Figma.
transition
Makes a change happen smoothly over time instead of all at once.
opacity
How see-through something is, from 0 (invisible) to 1 (solid).
event
A signal the browser sends when something happens, like a key being pressed.
focus
Which thing the keyboard is on right now. Tab moves it along.
mix-blend-mode
How a layer mixes with what is under it, like blend modes in Figma. Multiply darkens.

How it works

A quick map first. A component is a reusable piece of interface, like a component in Figma. Props are the settings and content you pass into it, like a title or a colour. The files end in .tsx: interface code written in TypeScript, which is JavaScript that also checks what kind of value each setting expects.

Point at a step, tap it or Tab to it, to light up its lines in the code.

  1. 01

    The spines

    Every book stands on the same shelf line, with its own height, thickness and type, and one leans on its neighbour.

    1. The books sit in one row, lined up by their bottoms, so they all stand on the same shelf (flexbox, alignItems: 'flex-end').
    2. The words run down the spine: the text is turned on its side (writing-mode: vertical-rl). The title's size follows the spine's thickness, and shrinks if a long title wouldn't fit.
    3. A leaning book turns from its bottom corner (transform-origin), like a book resting against the one next to it.
    4. It gets extra space on the side it leans, worked out from its height and angle (a little trigonometry, Math.sin), so it rests on its neighbour instead of cutting into it.
    BookStack.tsx
    // books stand on one shelf line: the row lines them up by their bottoms
    <div style={{ display: 'flex', alignItems: 'flex-end', gap: 12, height: tallest }}>
    
    // …
    
    // a leaning book needs room on that side so it rests on its neighbour instead of cutting into it
    const leanRoom = Math.round(height * Math.sin((Math.abs(lean) * Math.PI) / 180))
    // title size follows the spine's thickness, but shrinks if a long title wouldn't fit its height
    const titleSize = Math.min(
      Math.round(width * 0.4),
      Math.floor((height - 48 - b.author.length * 6.5) / (b.title.length * letterWidth[b.type ?? 'serif'])),
    )
    
    // …
    
    // lean from the bottom corner, like a book tipped against the one next to it
    transform: open ? 'rotate(0deg)' : `rotate(${lean}deg)`,
    transformOrigin: lean > 0 ? 'bottom left' : 'bottom right',
    marginRight: !open && lean > 0 ? leanRoom : 0,
    marginLeft: !open && lean < 0 ? leanRoom : 0,
    
    // …
    
    {/* text runs down the spine: vertical-rl turns the line 90° */}
    <span style={{ writingMode: 'vertical-rl', fontFamily: type, fontSize: 11, letterSpacing: '0.04em', opacity: 0.75 }}>
      {b.author}
    </span>
    <span style={{ writingMode: 'vertical-rl', fontFamily: type, fontSize: titleSize, lineHeight: 1, whiteSpace: 'nowrap' }}>
      {b.title}
    </span>

    This is part of the file. Copy the complete file from the Code tab.

  2. 02

    The pull-out

    Click a spine and it grows into a wide card, and the books next to it slide over to make room.

    1. The shelf remembers which book is out (useState). There is only one, so opening a book puts the last one back.
    2. Each book has two sizes: a thin spine, or a wide card as tall as the tallest book on the shelf.
    3. The change in size plays over 0.6 seconds (transition), starting fast and settling slowly (easing).
    4. Nothing moves the other books: they share the row, so as one gets wider, the rest slide over by themselves. Like auto layout in Figma.
    BookStack.tsx
    const [openId, setOpenId] = useState<string | null>(defaultOpen ?? null) // only one book out at a time
    
    // …
    
    // one book, two sizes: a thin spine, or pulled out to a wide card as tall as the tallest book
    width: open ? openWidth : width,
    height: open ? tallest : height,
    
    // …
    
    // neighbours are in the same row, so they slide over on their own as this one grows
    transition: `width 0.6s ${ease}, height 0.6s ${ease}, transform 0.6s ${ease}, margin 0.6s ${ease}`,

    This is part of the file. Copy the complete file from the Code tab.

  3. 03

    Spine to card

    The spine fades away as the book opens, and the card fades in once there is room for it. This uses the same open from 02.

    1. The spine fades out fast, in 0.15 seconds, as soon as you click (opacity).
    2. The card fades in 0.2 seconds later, once the book has started to widen.
    3. The card is always full size, so its text never squashes while the book grows: the widening book simply uncovers it, like a mask in Figma.
    BookStack.tsx
    // the spine fades out quickly as the book starts to open
    opacity: open ? 0 : 1,
    transition: `opacity ${open ? '0.15s' : '0.3s 0.25s'} ease`,
    pointerEvents: open ? 'none' : 'auto',
    
    // …
    
    // fixed size, so the text never squashes: the growing book simply uncovers it
    width: openWidth,
    height: tallest,
    opacity: open ? 1 : 0,
    transition: `opacity 0.35s ease ${open ? '0.2s' : '0s'}`,

    This is part of the file. Copy the complete file from the Code tab.

  4. 04

    Keyboard friendly

    Every spine is a real button, and Escape or × puts the book back where your focus was.

    1. While a book is open, the page listens for the Escape key (keydown event) and closes the book. The listener is connected when a book opens and disconnected when it closes (useEffect).
    2. Closing hands keyboard focus back to that book's spine (focus), so keyboard users never lose their place. useRef keeps hold of each spine to make that possible.
    BookStack.tsx
    function close(id: string) {
      setOpenId(null)
      spineRefs.current[id]?.focus() // hand focus back to the spine it came from
    }
    
    // Escape puts the open book back
    useEffect(() => {
      if (!openId) return
      const id = openId
      const onKey = (e: KeyboardEvent) => e.key === 'Escape' && close(id)
      window.addEventListener('keydown', onKey)
      return () => window.removeEventListener('keydown', onKey)
    }, [openId])

    This is part of the file. Copy the complete file from the Code tab.

  5. 05

    Each book, its own look

    Every book carries its own colours, and a pulled-out cover can differ from its spine, like a dust jacket.

    1. Each book can set its own spine colour (spine) and text colour (ink). If it doesn't, it uses the shelf's white and blue.
    2. The cover can be different (cover and coverInk): The Artist's Way has a neon spine but opens to a bluish-green cover.
    3. When the cover is different, a 12px strip of the spine colour shows down its left edge, so you can still tell it's the same book.
    4. Old books (aged: true) get a worn finish: paper grain (SVG noise filter, feTurbulence) and darker edges, blended over the colour (mix-blend-mode: multiply) so the title still shows through.

    You don't need to read the long line in agedFinish. It is a tiny noise image written as text, used for the worn paper on old books.

    BookStack.tsx
    // Old-book finish: paper grain plus darker, worn edges.
    const agedFinish =
      "radial-gradient(ellipse at center, transparent 55%, rgba(70,55,35,0.3 … %3C/svg%3E\")"
    
    // …
    
    // each book carries its own colours; the cover can differ from the spine, like a real dust jacket
    const spine = b.spine ?? paper
    const spineInk = b.ink ?? ink
    const cover = b.cover ?? spine
    const coverInk = b.coverInk ?? spineInk
    
    // …
    
    background: spine,
    color: spineInk,
    
    // …
    
    {/* aged books get grain and worn edges, multiplied over the spine so the text still shows */}
    {b.aged && (
      <div aria-hidden="true" style={{ position: 'absolute', inset: 0, pointerEvents: 'none', backgroundImage: agedFinish, mixBlendMode: 'multiply', opacity: 0.6, zIndex: 1 }} />
    )}
    
    // …
    
    background: cover,
    color: coverInk,
    // leave room for the strip of spine showing on the left
    padding: cover !== spine ? '22px 22px 22px 34px' : 22,
    
    // …
    
    {/* a different cover shows a thin strip of the spine down its left edge */}
    {cover !== spine && (
      <div aria-hidden="true" style={{ position: 'absolute', top: 0, bottom: 0, left: 0, width: 12, background: spine }} />
    )}

    This is part of the file. Copy the complete file from the Code tab.

  6. 06

    Adding a book

    A book added to the shelf drops into place, while the books already there stay still. Try the + below the shelf in the preview.

    1. The shelf keeps a list of the books it has already shown (useRef). On the first load it just notes them all, so nothing animates.
    2. A book it hasn't seen before gets a quick drop: it starts 40px higher and see-through, and lands on the shelf in 0.45 seconds (element.animate).
    3. The drop keeps the book's lean, so a tilted book lands tilted.
    4. Take the open book off the shelf and it closes, so nothing is left half open.
    5. If your device is set to reduce motion (prefers-reduced-motion), new books simply appear.
    BookStack.tsx
    // Books added after the first render drop onto the shelf. The first render is left alone.
    const seen = useRef<Set<string> | null>(null)
    useEffect(() => {
      if (!seen.current) {
        seen.current = new Set(books.map((b) => b.id))
        return
      }
      const still = window.matchMedia('(prefers-reduced-motion: reduce)').matches
      for (const b of books) {
        if (seen.current.has(b.id)) continue
        seen.current.add(b.id)
        if (still) continue // no drop for people who asked for less motion
        const lean = `rotate(${b.lean ?? 0}deg)`
        bookRefs.current[b.id]?.animate(
          [
            { opacity: 0, transform: `translateY(-40px) ${lean}` },
            { opacity: 1, transform: `translateY(0) ${lean}` },
          ],
          { duration: 450, easing: ease },
        )
      }
      // a removed book can come back later and drop in again
      for (const id of seen.current) if (!books.some((b) => b.id === id)) seen.current.delete(id)
    }, [books])
    
    // if the open book is taken off the shelf, nothing is open any more
    useEffect(() => {
      if (openId && !books.some((b) => b.id === openId)) setOpenId(null)
    }, [books, openId])

    This is part of the file. Copy the complete file from the Code tab.

Use it

The buttons above copy the complete files. The code in How it works is only excerpts, so copy from here.

  1. You need Node.js installed. In a terminal, run npx create-next-app@latest my-shelf --yes to make a new Next.js project, then cd my-shelf.
  2. Inside the app folder, make a folder called components. Copy the complete file with the button above and save it as app/components/BookStack.tsx.
  3. Open app/page.tsx, delete everything in it, and paste the example below.
  4. Run npm run dev and open localhost:3000. Click a book.
  5. Change these first: each book's title and author, its size (width and height) and its colours (spine and ink). To add a book, copy one { … } line, give it a new id, and change the rest.
app/page.tsx
import BookStack from './components/BookStack'

export default function Page() {
  return (
    <main style={{ background: '#3336A3', minHeight: '100vh', display: 'grid', placeItems: 'center' }}>
      <BookStack
        books={[
          { id: 'a', title: 'Tiny Experiments', author: 'Anne-Laure Le Cunff', year: '2025', width: 62, height: 380, type: 'sans', spine: '#f3ead6' },
          { id: 'b', title: "The Artist's Way", author: 'Julia Cameron', year: '1992', width: 58, height: 450, spine: '#e4ff5d', ink: '#16706c' },
          { id: 'c', title: 'The Creative Act', author: 'Rick Rubin', width: 84, height: 380, lean: -9, type: 'sans', spine: '#c9c9c6', ink: '#1a1a1a' },
        ]}
      />
    </main>
  )
}
What you can change 20
<BookStack /> 5
booksrequired
The books on the shelf, left to right. Each one's parts are listed below.
paperoptional
The spine colour for books that don't set their own. Default #fbfaf6.
inkoptional
The text colour for books that don't set their own. Default #3336A3.
openWidthoptional
How wide a pulled-out book gets, in pixels. Default 340.
defaultOpenoptional
The id of a book to start pulled out.
Inside each book 15
idrequired
A short name that's different for every book, like artists-way.
titlerequired
The title, on the spine and the card.
authorrequired
The author, on the spine and the card.
yearoptional
Shown after the author on the card.
tagsoptional
Small labels under "Filed under" on the card, like ['creativity', 'process'].
linkoptional
A link at the bottom of the card, like { label: 'Watch the video ↗', href: 'https://…' }.
widthoptional
How thick the spine is, in pixels. Default 64.
heightoptional
How tall the book is, in pixels. Default 420.
leanoptional
Tips the book, in degrees: plus leans right, minus leans left. Try -9.
typeoptional
The lettering: serif, sans, hand or mono. Default serif.
spineoptional
The spine colour, as a 6-digit hex like #e4ff5d.
inkoptional
The spine text colour.
coveroptional
The pulled-out card colour, if it should differ from the spine.
coverInkoptional
The card text colour.
agedoptional
Set to true for a worn, grainy old book.
Dependencies 3
  • React 18+useState, useRef and useEffect. Nothing else to install.
  • No animation libraryThe pull-out is a CSS transition on width and height. The drop for a new book uses the browser's built-in Web Animations API.
  • Dokdo + Space GroteskOptional fonts (Dokdo, Space Grotesk), read from --font-dokdo and --font-space-grotesk. Falls back to cursive and sans-serif.
Things to learn next 7