The third video ad we built for Nakodo is a book. It drops onto a desk, the cover opens, twenty pages riffle past under a thumb, and it stops on the first of three spreads. It is React, rendered with Remotion, and there is no 3D library anywhere in it.
The thing I expected to be hard, the page turn, is nine lines of CSS. The thing that actually took the afternoon was deciding which page is on top.
A book in a 9:16 frame
The first decision is not an animation one. A book is landscape and a reel is portrait, so an honest drawing of an open book in a vertical video gives you two tiny pages and a lot of desk.
So the spine sits just inside the left edge:
export const BOOK = { left: 70, top: 250, width: 940, height: 1360 };
export const COVER = { left: BOOK.left - 6, top: BOOK.top - 28, width: BOOK.width + 42, height: BOOK.height + 56 };
The right-hand page fills the frame. The left-hand page exists, and is drawn, at left - width, which is off screen. You never see it, but pages have to turn onto something, and the shadow near the spine only reads as a gutter if there is a page under it.
COVER is the same box plus a margin on three sides, because a hardback's boards overhang its pages, and that overhang is most of what makes a rectangle read as a book.
Every leaf is a row in one array
type Leaf = {
start: number;
turn: number;
ease: (t: number) => number;
front: (f: number) => ReactNode;
back?: ReactNode;
box?: typeof BOOK;
};
The whole book is one array in that shape: the cover first, then twenty riffle pages, then the three content pages, then the call to action.
const LEAVES: Leaf[] = [
{ start: COVER_OPEN, turn: COVER_TURN, ease: easeInOut, front: () => <Cover />, back: <CoverInside />, box: COVER },
...Array.from({ length: RIFFLE_PAGES }, (_, k): Leaf => ({
start: RIFFLE_STARTS[k],
turn: riffleTurn(k),
ease: smooth,
front: () => <Paper><RifflePage k={k} /></Paper>,
})),
...PAGES.map((Page, i): Leaf => ({ start: TURN_AT[i], turn: TURN, ease: easeInOut, front: (f) => <Paper><Page t={f - PAGE_AT[i]} /></Paper> })),
{ start: 1e9, turn: 1, ease: smooth, front: (f) => /* the end card */ },
];
front is a function of the frame, not a node, because the content of a page animates while the page lies there: a word gets pecked and turns into another word. back is a plain node because the back of a page is blank in every case but one.
start: 1e9 on the last leaf is the laziest possible way to say "this one never turns over". It is the last page; the video ends while it is open. A turnsOver: false flag would have been one more branch in the renderer for one row in the data.
The turn
export function Turning({ angle, front, back, box = BOOK }) {
if (angle >= 179.5) return null;
const { left, top, width, height } = box;
const lift = Math.sin((Math.min(angle, 90) * Math.PI) / 180);
return (
<div style={abs({ left, top, width, height, perspective: 4200, perspectiveOrigin: `0px ${height / 2}px` })}>
<div style={abs({ inset: 0, transformOrigin: "0 50%", transformStyle: "preserve-3d", transform: `rotateY(${-angle}deg)` })}>
<div style={abs({ inset: 0, backfaceVisibility: "hidden" })}>
{front}
<div style={abs({ inset: 0, background: `rgba(20,30,50,${0.16 * lift})` })} />
</div>
<div style={abs({ inset: 0, backfaceVisibility: "hidden", transform: "rotateY(180deg)" })}>{back ?? <Paper />}</div>
</div>
</div>
);
}
That is the entire mechanism. transformOrigin: "0 50%" puts the hinge on the spine. rotateY(-angle) swings it, 0 lying flat and 180 turned over. Two children both with backfaceVisibility: hidden, the second pre-rotated by 180 degrees, so exactly one of them faces you at any angle and they swap at upright on their own.
perspectiveOrigin at 0px, height/2 puts the vanishing point on the hinge rather than in the middle of the page, which is the difference between a page turning and a page being swung around like a door.
lift darkens the front as it rises, peaking at upright, using sin of the angle clamped to 90. No lighting model, one multiply.
The early return at 179.5 rather than 180 is a practical detail. A page lying flat on the left page is off screen and perfectly flush with whatever is under it, and leaving it mounted means every landed page contributes another composited layer for the rest of the video.
Which page is on top
This is the part that is not obvious, and it is six lines:
const angles = LEAVES.map((l) => angleOf(l, f));
const open = angles.findIndex((a) => a === 0);
const turning = LEAVES.map((l, i) => ({ l, i, a: angles[i] })).filter(({ a }) => a > 0 && a < 180);
const lowest = turning.length ? turning[turning.length - 1].a : 0;
At any frame a leaf is in one of three states, straight out of its angle: not started (0), turning (0 to 180), or done (180). So:
- the page you can see lying open is the first leaf that has not started to turn, found with a
findIndexfor a zero; - everything turning is drawn after it, in reverse array order, because a leaf later in the book sits under the ones before it, and the ones before it have turned further;
-
lowestis the angle of the last leaf still turning, which is the one closest to the page below, and that is the angle the cast shadow is drawn from.
{turning.slice().reverse().map(({ l, i, a }) => <Turning key={i} angle={a} front={l.front(f)} back={l.back} box={l.box} />)}
Nothing in the data says what is in front of what. Z-order falls out of array order plus the angles, which means adding a page to the book is adding an object to an array and nothing else. The first version of this had explicit z-index numbers and I got twenty pages into the riffle before I admitted that I was maintaining a second copy of the ordering by hand.
The riffle is two easing curves, not twenty keyframes
const gapAt = (k: number) => 2 + 2.6 * (1 - Math.sin((Math.PI * k) / (RIFFLE_PAGES - 1)));
export const RIFFLE_STARTS = Array.from({ length: RIFFLE_PAGES }, (_, k) => k)
.reduce<number[]>((s, k) => [...s, k === 0 ? RIFFLE_FROM : s[k - 1] + gapAt(k)], []);
export const riffleTurn = (k: number) => 6 + 4 * (1 - Math.sin((Math.PI * k) / (RIFFLE_PAGES - 1)));
A thumb released off a stack of pages is slow at the start, fast through the middle and slows to a stop. Both the gap between pages starting and the time each page takes to turn follow the same half-sine: 4.6 frames apart at the ends, 2 frames apart in the middle.
The starts are a cumulative sum, because what you control is the gap and what the renderer needs is the time. Twenty rows of hand-tuned numbers would have been the obvious thing to write and impossible to retime later; this way the whole riffle is two constants and a curve.
The shadow
const reach = width * Math.cos((angle * Math.PI) / 180);
const k = Math.sin((angle * Math.PI) / 90) * 0.28;
A lifting page casts a shadow onto the page under it, and the shadow is as wide as the page's own projection: width * cos(angle). At 0 it covers the page exactly, at 90 it has gone.
The darkness uses angle * PI / 90, not /180. That doubles the frequency, so the sine peaks at 45 degrees and returns to zero at 90, which is roughly when a real page is darkest against the one below: not flat, not upright, somewhere in between.
The end card is a whole screen stuffed into a page
The last leaf has to show the same call to action as our other two reels, which is a component laid out against the full 1080 by 1920 frame, not against a 940 by 1360 page.
<Paper>
<div style={abs({ left: -BOOK.left - 4, top: -BOOK.top - 4, width: W, height: H })}>
<EndCard t={endT(f)} alcy={false} background={C.paper0} headline={<>Find creators<br />who say yes.</>} />
</div>
</Paper>
Shift it back by the page's own offset and let Paper's overflow: hidden crop it. The alcy={false} prop exists because the shared end card normally flies our bird in to press the button, and in this reel the bird is already on the page and walks to it instead.
Was the book the right idea
The reel argues three things: that the wrong creator will not film your product, that a DM sinks under everybody else's, and that a guessed fee gets no reply. Those are the same arguments as the written guide to vetting a creator and the sponsorship cost calculator, which is the point: the ad is a trailer for pages that already existed.
A field guide was the right container for "three reasons" because turning a page is a free scene transition that an audience already understands. The cost is this file. If you only need one transition, use a crossfade.
The thing I would take to the next project is the leaf array. Any sequence where things overlap in time and stack in space wants to be one list of objects with a start, a duration and a renderer, with the stacking derived rather than stored. The moment the ordering lives in two places, one of them is already wrong.
Top comments (0)