Detail 14 · Detailing
A book you can leaf through
The case study as a real book: sheets that only ever turn, a page that stays under your finger, corners that peel, the pages you aren’t reading, and the sound of a turn. 9 minutes, 4 demos.
01 · The idea
If it looks like a book, it has to turn like one
The case study’s page, the one that lists these details, was a tree of sheets once, then a case file. Now it’s a book: a cloth-bound sketchbook that waits closed under the title, opens when you click it, and gives every detail a spread. It’s the site’s idea, a designer’s sketchbook, made literal.
A drawing of a book is a picture. A book you can open makes a promise: people will handle it like paper, grabbing a page and dragging it over. Every way it behaves unlike paper breaks the promise, and this detail is about the small ways it could have.
02 · The flicker
A page is never swapped mid-turn
The first version had one sheet that turned. It showed the page you were leaving on its front and the next page on its back, and once it landed, the spread beneath was brought up to date. Until then, under the lifting sheet, lay the page you were turning, a copy of it, which jumped to the right page as the turn ended. For a moment, the old page showed again.
Below, switch off “Every sheet mounted” and slow the turn down: under the page in the air is the same page, until it lands.
Now every sheet of paper is always there, one leaf per sheet: its front a right page, its back the next left page. A turn rotates a leaf around the spine and does nothing else. What a page shows is fixed when the book is built, so there’s no moment in which anything stale can show. Which face you see, and which leaf lies on top, is worked out from the angle alone.
function Leaf({ index, angle, board, twoUp, front, back, frontLive, backLive }) {
const transform = useTransform(angle, (a) => `perspective(${PERSPECTIVE}px) rotateY(${a}deg)`);
const zIndex = useTransform(angle, (a) => (a > -90 ? 1000 - index : 2 * index + 1));
const showFront = useTransform(angle, (a) => (a > -90 ? "visible" : "hidden"));
const showBack = useTransform(angle, (a) => (a > -90 ? "hidden" : "visible"));
const shade = useTransform(angle, (a) => Math.sin(Math.min(Math.max(-a / 180, 0), 1) * Math.PI) * 0.4);On the right, the leaf nearest the reader is on top; once over the spine, the last one turned is. Only odd numbers are used on the left, so the block of page edges can slide in between. It’s cheap: thirty-odd pages, nothing drawn twice, and a page you can’t see is inert, so Tab never lands on a link hidden under another page.
03 · The drag
The page stays under your finger
Drag a page, and the spot you took hold of should stay under your finger the whole way over. A page turning around the spine is a circle seen edge on: a point grip px from the spine sits grip × cos θ across the screen. So a drag doesn’t set the angle from the distance moved. It sets the cosine, and the angle comes from that.
The ember ring is where you took hold. Switch off “Follow the paper” for the obvious mapping, the angle in step with the distance, and the page runs ahead of your finger at first and lags behind it at the end.
const onPointerMove = (e) => {
const p = press.current;
if (!p) return foldTowards(e);
const dx = e.clientX - p.x;
const dy = e.clientY - p.y;
if (!p.drag) {
// Mostly up or down: that's a scroll, not a turn
if (Math.abs(dy) > DRAG && Math.abs(dy) > Math.abs(dx)) return (press.current = null);
if (Math.abs(dx) < DRAG) return;
p.drag = grab(p, dx < 0);
if (!p.drag) return (press.current = null);
bookRef.current.setPointerCapture(e.pointerId);
}
const { leaf, grip, start } = p.drag;
const cos = Math.min(Math.max(start + dx / grip, -1), 1);
angles[leaf].set((-Math.acos(cos) * 180) / Math.PI);
};The rest is what a hand expects. A press that moves less than 6px is a click: on the right page it turns on, on the left back. A drag that’s mostly up or down is a scroll, so the book never traps a phone’s page. Links and buttons on the paper stay links and buttons. Letting go decides: past upright the page turns, short of it it falls back, unless it was flicked, faster than 400°/s, which carries it over either way.
const release = ({ leaf, forward }) => {
const angle = angles[leaf].get();
const speed = angles[leaf].getVelocity();
// Past upright, or flicked: it turns; otherwise it falls back
const turns = forward ? angle < -90 || speed < -FLICK : angle > -90 || speed > FLICK;
if (turns) {
settle(forward ? leaf + 1 : leaf);
playPageTurn();
}
animate(angles[leaf], forward === turns ? -180 : 0, SPRING.morph);
};04 · The corner
A corner that peels
Bring the mouse near a page’s outer corner and it peels over, further the nearer you get; point anywhere on a page and its bottom corner lifts a little, to say it turns. The first fold was a dark triangle. Paper doesn’t fold like that: the crease runs steeply up the page, the corner flips over onto the paper, and where it was, you see the page beneath.
The geometry is one reflection. The crease meets the foot s px from the corner and the outer edge 2.4 times as high, and the flap’s tip is the corner mirrored across the crease. Switch on “Show the geometry” to see it.
const FOLD_UNIT = 100; // the crease meets the foot this far from the corner…
const FOLD_HEIGHT = FOLD_UNIT * FOLD_RATIO; // …and the outer edge this far up
// The corner, reflected across the crease: the flap's tip
const FOLD_SPAN = 1 / FOLD_UNIT ** 2 + 1 / FOLD_HEIGHT ** 2;
const TIP = [2 / FOLD_UNIT / FOLD_SPAN, 2 / FOLD_HEIGHT / FOLD_SPAN];
const CREASE = [TIP[0] / 2, TIP[1] / 2]; // the crease's nearest point to the cornerThen the page itself is cut along the crease, so the real page under it shows through, content and all, with only a soft shadow at the crease. Switch off “Cut the page away” and the flap lies on its own page, the way the first version drew it.
export function sheetClip([tl, tr, bl, br]) {
if (Math.max(tl, tr, bl, br) <= 0.5) return "none";
const up = (s) => (s * FOLD_RATIO).toFixed(1);
const points = [
...(tl > 0.5 ? [`0 ${up(tl)}px`, `${tl.toFixed(1)}px 0`] : ["0 0"]),
...(tr > 0.5 ? [`calc(100% - ${tr.toFixed(1)}px) 0`, `100% ${up(tr)}px`] : ["100% 0"]),
...(br > 0.5 ? [`100% calc(100% - ${up(br)}px)`, `calc(100% - ${br.toFixed(1)}px) 100%`] : ["100% 100%"]),
...(bl > 0.5 ? [`${bl.toFixed(1)}px 100%`, `0 calc(100% - ${up(bl)}px)`] : ["0 100%"]),
];
return `polygon(${points.join(", ")})`;
}05 · The rest of the book
Everything you aren’t reading
Open a real book and you see the rest of it: the pages you’ve read on the left, the ones to come on the right, a block of edges as thick as the paper. Here each side has a strip at its outer edge and a thinner one at its foot (the book is seen a little from above), as thick as there are sheets on that side, and the thickness moves across as you read.
The first version drew a copy of the page’s edge for every sheet, a fraction of a pixel apart, and it looked faded. It’s the grain’s problem again: anything that lands between pixels is blended with its neighbours, and thirty blended edges are a grey smear. Now every sheet is one pale pixel and one dark one, so each sheet resolves.
// Whole pixels per sheet, so every one resolves on any screen
backgroundImage: `linear-gradient(to ${outward}, rgb(0 0 0 / 0.6), transparent 3px, transparent 40%, rgb(0 0 0 / 0.45)), repeating-linear-gradient(to ${outward}, ${SHEET_EDGE} 0 1px, rgb(0 0 0 / 0.6) 1px 2px)`,Then the small things. At the head and foot of the spine the cloth bends round into a hollow, with a crease either side. Closed, the cover lifts a little under the mouse, to say it opens, and on it is the home page, sketched by the same wireframe the page transitions draw. A ribbon hangs from the foot. The line under the book, where you are and what’s there, rearranges its letters as it changes, like all the changing text on this site.
And a page makes a sound as it turns: the sheet swishing through the air, a crinkle as it bends over, a soft pat as it lands. It’s three bursts of filtered noise, made on the spot with Web Audio, so there are no files to load. Riffling to a detail from the contents plays a quick flick for each page instead.
export function playPageTurn({ delay = 0, light = false } = {}) {
try {
const turn = getSound();
if (!turn) return;
if (turn.ctx.state === "suspended") turn.ctx.resume();
const at = turn.ctx.currentTime + delay;
burst(turn, at, { duration: light ? 0.15 : 0.34, from: 900, to: 3800, peak: light ? 0.35 : 0.6, q: 0.7 });
if (!light) burst(turn, at + 0.12, { duration: 0.13, from: 3400, to: 5600, peak: 0.22, q: 1.4 });
burst(turn, at + (light ? 0.13 : 0.36), { duration: light ? 0.06 : 0.11, from: 700, to: 280, peak: light ? 0.3 : 0.55, type: "lowpass" });
} catch {06 · Decisions
Why it’s built this way
Turn, never swap
What a page shows is decided once. A turn only changes an angle, so there’s no frame in which a stale page can show.
The finger is right
Map the drag through the geometry of what moves, not a constant, and the page goes where the hand is: held, not driven.
Show what's really there
A fold shows the real next page, not a painted shadow; the edges are as thick as what’s left to read.
Sound only for you
A turn sounds only when you turned it, quietly, never on its own. That’s also when browsers let sound start.
07 · Takeaways
What to keep
- If it looks physical, people will handle it physically: every way it behaves otherwise is a bug.
- Keep everything mounted and only transform it; never swap what something shows in the middle of its animation.
- Map input through the geometry of what moves, so it stays under the pointer.
- Reveal the real thing beneath rather than painting an imitation of it.
- Draw anything finely repeated in whole pixels, or it fades to grey.
It’s on the case study’s page: open it, drag a page by its corner, and listen.