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.
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.
- 01
The spines
Every book stands on the same shelf line, with its own height, thickness and type, and one leans on its neighbour.
- The books sit in one row, lined up by their bottoms, so they all stand on the same shelf (flexbox,
alignItems: 'flex-end'). - 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.
- A leaning book turns from its bottom corner (transform-origin), like a book resting against the one next to it.
- 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.
- The books sit in one row, lined up by their bottoms, so they all stand on the same shelf (flexbox,
- 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.
- The shelf remembers which book is out (useState). There is only one, so opening a book puts the last one back.
- Each book has two sizes: a thin spine, or a wide card as tall as the tallest book on the shelf.
- The change in size plays over 0.6 seconds (transition), starting fast and settling slowly (easing).
- 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.tsxconst [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.
- 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
openfrom 02.- The spine fades out fast, in 0.15 seconds, as soon as you click (opacity).
- The card fades in 0.2 seconds later, once the book has started to widen.
- 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.
- 04
Keyboard friendly
Every spine is a real button, and Escape or × puts the book back where your focus was.
- 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).
- 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.tsxfunction 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.
- 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.
- 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. - The cover can be different (
coverandcoverInk): The Artist's Way has a neon spine but opens to a bluish-green cover. - 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.
- 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.
- Each book can set its own spine colour (
- 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.
- 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.
- 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).
- The drop keeps the book's lean, so a tilted book lands tilted.
- Take the open book off the shelf and it closes, so nothing is left half open.
- 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.
- You need Node.js installed. In a terminal, run
npx create-next-app@latest my-shelf --yesto make a new Next.js project, thencd my-shelf. - Inside the
appfolder, make a folder calledcomponents. Copy the complete file with the button above and save it asapp/components/BookStack.tsx. - Open
app/page.tsx, delete everything in it, and paste the example below. - Run
npm run devand open localhost:3000. Click a book. - Change these first: each book's
titleandauthor, its size (widthandheight) and its colours (spineandink). To add a book, copy one{ … }line, give it a newid, and change the rest.
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
idof 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,handormono. Defaultserif. 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
truefor 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
- React: Rendering lists ↗How one list of books becomes a whole shelf, and why each needs an id.
- React: State, a component's memory ↗How useState remembers which book is out.
- React: Manipulating the DOM with refs ↗How useRef reaches a spine to give it focus.
- MDN: Basic concepts of flexbox ↗Rows, columns and lining things up, the CSS behind auto layout.
- MDN: writing-mode ↗Every way to turn text, with examples.
- MDN: Using the Web Animations API ↗Playing a quick animation from code, like the drop.
- cubic-bezier.com ↗Drag an easing curve around and watch it move. Try a bouncier pull-out.