About
The leaf model of the studio's own brochure, lifted out as an item. Each leaf is a thin box whose middle surface is integrated along the page every time it moves, so it can bend like paper: the edge leads as the sheet lifts and trails as it lands, the near corner twists a little, and resting pages rise out of the spine by the same curve for every sheet so none comes up through another. A small controller turns leaves one after another toward a requested spread, takes a drag (the page follows the hand with a little resistance and finishes or falls back by how far it got), and peels the corner of whichever page is physically on top. The scene around it draws only while something moves and paints the pages from props with the 2D canvas.
Use it when
- A hotel, restaurant, studio or event wants its brochure, menu or rate card as an object.
- A portfolio wants a lookbook to page through.
- A launch wants a product booklet above the fold.
Skip it when
- The content is a long document: a PDF viewer is better.
- The page already has a 3D centrepiece.
Install
- 1Add the VibeBox registry to
components.json:{ "registries": { "@vibebox": "https://motion.vibeboxph.com/r/{name}.json" } } - 2Add the component with the shadcn CLI:
npx shadcn@latest add @vibebox/brochure - 3Use it:
import { Brochure } from "@/components/vibebox/brochure"; <Brochure className="aspect-[16/10] w-full max-w-4xl rounded-xl" pages={[ { kind: "cover", eyebrow: "Casa Alon", title: "A weekend, written down." }, { eyebrow: "The rooms", title: "Six rooms by the sea", text: "Each room has its own door to the garden." }, { kind: "cover", title: "See you soon." }, ]} />
Code
4 filescomponents/vibebox/brochure/brochure.book.ts
/**
* VibeBox Motion · Brochure
* Copyright (c) 2026 VibeBox. Licensed under the VibeBox Motion Free License:
* https://motion.vibeboxph.com/license
* Use it in unlimited personal, commercial and client projects.
* Do not sell or redistribute it as a component, kit, template or builder.
*/
import * as THREE from "three";
/* A book: saddle-stitched leaves on a spine along Z. Each leaf is a thin box (front
face, back face, real paper edges) whose mid-surface is integrated along the page every
time it moves, so it can bend like paper: the edge leads as the sheet lifts, trails as
it lands, the near corner twists a little, and resting pages rise out of the spine.
Units are metres: an A4 page is 0.21 x 0.297. */
export const PAGE_W = 0.21;
export const PAGE_H = 0.297;
export const SEG_X = 40;
export const SEG_Z = 6;
const clamp01 = (value: number) => Math.min(1, Math.max(0, value));
const mix = (a: number, b: number, t: number) => a + (b - a) * t;
/** Ease for a whole turn: the sheet gathers, sweeps, and settles. */
export const easeTurn = (t: number) => (t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2);
export type LeafMaterials = { front: THREE.Material; back: THREE.Material; edge: THREE.Material };
export class Leaf {
readonly index: number;
readonly mesh: THREE.Mesh<THREE.BoxGeometry, THREE.Material[]>;
readonly thickness: number;
readonly stiffness: number;
/** Stack heights under this leaf when it rests on the right and on the left. */
liftRight = 0;
liftLeft = 0;
/** 0 lying on the right, 1 lying on the left. */
t = 0;
/** Hover: the near outer corner lifts toward the reader. */
peel = 0;
/** Where a drag gripped the page, -1 far edge to 1 near edge. */
grip = 0.6;
private readonly rest: Float32Array;
private readonly nodeOf: Uint16Array;
private readonly nodeX: Float32Array;
private readonly nodeY: Float32Array;
private readonly nodePhi: Float32Array;
private posed = "";
/** The height of the page's middle at the spine, as of the last pose. */
private lifted = 0;
constructor(index: number, thickness: number, stiffness: number, materials: LeafMaterials) {
this.index = index;
this.thickness = thickness;
this.stiffness = stiffness;
const geometry = new THREE.BoxGeometry(PAGE_W, thickness, PAGE_H, SEG_X, 1, SEG_Z);
geometry.translate(PAGE_W / 2, 0, 0);
const position = geometry.attributes.position as THREE.BufferAttribute;
this.rest = Float32Array.from(position.array as Float32Array);
// Every vertex sits on the (SEG_X + 1) x (SEG_Z + 1) grid of the page.
this.nodeOf = new Uint16Array(position.count);
for (let vertex = 0; vertex < position.count; vertex++) {
const i = Math.round((this.rest[vertex * 3]! / PAGE_W) * SEG_X);
const j = Math.round(((this.rest[vertex * 3 + 2]! + PAGE_H / 2) / PAGE_H) * SEG_Z);
this.nodeOf[vertex] = j * (SEG_X + 1) + i;
}
const nodes = (SEG_X + 1) * (SEG_Z + 1);
this.nodeX = new Float32Array(nodes);
this.nodeY = new Float32Array(nodes);
this.nodePhi = new Float32Array(nodes);
// Box groups: +x outer edge, -x spine, +y front, -y back, +z near edge, -z far edge.
this.mesh = new THREE.Mesh(geometry, [materials.edge, materials.edge, materials.front, materials.back, materials.edge, materials.edge]);
this.mesh.castShadow = true;
this.mesh.receiveShadow = true;
this.mesh.frustumCulled = false;
this.mesh.renderOrder = index;
this.pose(true);
}
/** Recomputes the sheet's shape from t, peel and grip. Cheap when nothing changed. */
pose(force = false) {
const key = `${this.t.toFixed(4)}|${this.peel.toFixed(3)}|${this.grip.toFixed(2)}|${this.liftRight}|${this.liftLeft}`;
if (!force && key === this.posed) return false;
this.posed = key;
const soft = 1 - this.stiffness;
const angle = Math.PI * this.t;
const wave = Math.sin(2 * Math.PI * this.t);
const bend = soft * 0.68 * wave;
const twist = soft * 0.34 * wave * (0.55 + 0.45 * this.grip);
const settled = this.t < 0.5 ? 1 - this.t * 2 : (this.t - 0.5) * 2;
// Every leaf, cover included, rises out of the spine by the same curve. A stiffer cover that
// rose less than the sheets under it let their pages come up inside (and, at the back, through)
// the board for a few centimetres beside the spine.
const gutter = 0.17 * settled * (this.t < 0.5 ? 1 : -1);
const peel = this.peel * (this.t < 0.5 ? 0.62 : -0.62);
const lift = mix(this.liftRight, this.liftLeft, this.t) + this.thickness / 2;
this.lifted = lift;
const step = PAGE_W / SEG_X;
const phiAt = (s: number, zn: number) => {
const spine = Math.max(0, 1 - s / 0.17);
const corner = Math.max(0, (s - 0.45) / 0.55);
const near = clamp01((zn + 0.2) / 0.7);
return angle + bend * s * s + twist * s * zn + gutter * spine * spine + peel * corner * corner * near;
};
for (let j = 0; j <= SEG_Z; j++) {
const zn = j / SEG_Z - 0.5;
let x = 0;
let y = 0;
for (let i = 0; i <= SEG_X; i++) {
const node = j * (SEG_X + 1) + i;
const s = i / SEG_X;
this.nodeX[node] = x;
this.nodeY[node] = y;
this.nodePhi[node] = phiAt(s, zn);
const mid = phiAt(Math.min(1, s + 0.5 / SEG_X), zn);
x += Math.cos(mid) * step;
y += Math.sin(mid) * step;
}
}
const position = this.mesh.geometry.attributes.position as THREE.BufferAttribute;
const array = position.array as Float32Array;
for (let vertex = 0; vertex < position.count; vertex++) {
const node = this.nodeOf[vertex]!;
const offset = this.rest[vertex * 3 + 1]!;
const phi = this.nodePhi[node]!;
array[vertex * 3] = this.nodeX[node]! - Math.sin(phi) * offset;
array[vertex * 3 + 1] = this.nodeY[node]! + Math.cos(phi) * offset + lift;
array[vertex * 3 + 2] = this.rest[vertex * 3 + 2]!;
}
position.needsUpdate = true;
this.mesh.geometry.computeVertexNormals();
return true;
}
/**
* The middle of the page along one row of its grid (0 at the far edge to SEG_Z at the near
* edge), as [x, y] from the spine to the outer edge, in the book's frame. The sheet occupies
* y plus or minus half its thickness. For tests and diagnostics: nothing in the scene reads it.
*/
centreLine(row: number): Array<[number, number]> {
const points: Array<[number, number]> = [];
for (let i = 0; i <= SEG_X; i++) {
const node = row * (SEG_X + 1) + i;
points.push([this.nodeX[node]!, this.nodeY[node]! + this.lifted]);
}
return points;
}
dispose() {
this.mesh.geometry.dispose();
}
}
type Tween = { from: number; to: number; start: number; duration: number };
export type TurnEvent = { leaf: number; direction: 1 | -1; duration: number; cover: boolean; quick: boolean; start: number };
/**
* The stack of leaves and how they move. `target` is the spread the reader asked for (the
* number of turned leaves); leaves turn toward it one after another.
*/
export class Book {
readonly group = new THREE.Group();
readonly leaves: Leaf[];
target = 0;
onTurn?: (event: TurnEvent) => void;
private readonly tweens = new Map<number, Tween>();
private lastStart = -Infinity;
private chainForward = true;
private drag: { leaf: number; side: "left" | "right"; progress: number } | null = null;
private hover: { side: "left" | "right"; amount: number; goal: number } = { side: "right", amount: 0, goal: 0 };
/** The leaf the hover peel is on now; the peel starts over (and eases in) when it changes. */
private hoverLeaf = -1;
private readonly turnSeconds: number;
constructor(leaves: Leaf[], options: { turnSeconds: number }) {
this.leaves = leaves;
this.turnSeconds = options.turnSeconds;
// Heights are sums from the bottom of each stack, so the lowest leaf rests at exactly 0.
let right = 0;
for (let index = leaves.length - 1; index >= 0; index--) {
leaves[index]!.liftRight = right;
right += leaves[index]!.thickness;
}
let left = 0;
for (const leaf of leaves) {
leaf.liftLeft = left;
left += leaf.thickness;
this.group.add(leaf.mesh);
}
}
get count() {
return this.leaves.length;
}
get busy() {
return this.tweens.size > 0 || this.drag !== null;
}
/**
* Asks for a spread. Every leaf is reconciled against it: leaves already there (or on
* their way) are left alone, a turn that has not started yet in the old direction is
* cancelled, and the rest turn in order, overlapping when the jump is far. A change of
* direction starts a fresh chain at once instead of queueing behind the old one.
*/
goTo(spread: number, now: number) {
const target = Math.max(0, Math.min(this.count, Math.round(spread)));
if (target === this.target && !this.drag) return false;
const forward = target > this.target;
const quick = Math.abs(target - this.target) > 1;
const duration = quick ? Math.max(0.5, this.turnSeconds * 0.6) : this.turnSeconds;
const gap = quick ? 0.085 : 0.16;
this.target = target;
if (forward !== this.chainForward) this.lastStart = -Infinity;
this.chainForward = forward;
let start = Math.max(now, this.lastStart + gap);
for (const leaf of forward ? this.leaves : [...this.leaves].reverse()) {
const goal = leaf.index < target ? 1 : 0;
const tween = this.tweens.get(leaf.index);
if ((tween ? tween.to : leaf.t) === goal) continue;
if (leaf.t === goal) {
this.tweens.delete(leaf.index);
continue;
}
const cover = leaf.index === 0 || leaf.index === this.count - 1;
const length = (cover ? duration * 1.18 : duration) * Math.max(0.35, Math.abs(goal - leaf.t));
this.tweens.set(leaf.index, { from: leaf.t, to: goal, start, duration: length });
this.onTurn?.({ leaf: leaf.index, direction: goal === 1 ? 1 : -1, duration: length, cover, quick, start });
this.lastStart = start;
start += gap;
}
return true;
}
/** Starts dragging the top page of one side; returns false if there is none. */
beginDrag(side: "left" | "right", grip: number) {
const index = side === "right" ? this.target : this.target - 1;
const leaf = this.leaves[index];
if (!leaf || this.tweens.has(index)) return false;
leaf.grip = grip;
this.drag = { leaf: index, side, progress: 0 };
this.hover.goal = 0;
return true;
}
dragTo(progress: number) {
if (!this.drag) return;
const leaf = this.leaves[this.drag.leaf]!;
this.drag.progress = clamp01(progress);
// Follow the hand with a little resistance at the start, like stiff paper.
const eased = this.drag.progress * (0.82 + 0.18 * this.drag.progress);
leaf.t = this.drag.side === "right" ? eased : 1 - eased;
}
/** Ends a drag: the page completes its turn or falls back, depending on how far it got. */
endDrag(now: number, velocity: number) {
if (!this.drag) return;
const { leaf: index, side, progress } = this.drag;
this.drag = null;
const leaf = this.leaves[index]!;
const complete = progress > 0.42 || velocity > 0.9;
const goal = side === "right" ? (complete ? 1 : 0) : complete ? 0 : 1;
const span = Math.abs(goal - leaf.t);
const duration = Math.max(0.22, this.turnSeconds * span * 0.9);
this.tweens.set(index, { from: leaf.t, to: goal, start: now, duration });
if (complete) {
this.target += side === "right" ? 1 : -1;
this.onTurn?.({ leaf: index, direction: side === "right" ? 1 : -1, duration, cover: index === 0 || index === this.count - 1, quick: false, start: now });
}
}
get dragging() {
return this.drag !== null;
}
setHover(side: "left" | "right" | null) {
if (side === null) {
this.hover.goal = 0;
return;
}
if (side !== this.hover.side && this.hover.amount > 0.02) {
this.hover.goal = 0;
return;
}
this.hover.side = side;
this.hover.goal = this.drag ? 0 : 1;
}
/** Advances every moving leaf; returns true while anything is still moving. */
update(now: number, dt: number) {
let moving = false;
for (const [index, tween] of this.tweens) {
const leaf = this.leaves[index]!;
const progress = clamp01((now - tween.start) / tween.duration);
if (progress <= 0) {
moving = true;
continue;
}
leaf.t = mix(tween.from, tween.to, easeTurn(progress));
if (progress >= 1) {
leaf.t = tween.to;
this.tweens.delete(index);
}
moving = true;
}
// Hover peel eases toward its goal on the top page of the hovered side: the top page where it
// really is. A turn starts the moment the reader asks, and the leaf that was on top is still
// lying on the stack for a few frames, so the page under it must not lift its corner through
// it (that was the pages showing through each other): while the top leaf is in the air, no
// leaf peels, and the peel eases in again on whichever leaf comes to the top.
const hoverIndex = this.topLeaf(this.hover.side);
if (hoverIndex !== this.hoverLeaf) {
this.hoverLeaf = hoverIndex;
this.hover.amount = 0;
}
const rate = 1 - Math.exp(-dt * 10);
this.hover.amount += (this.hover.goal - this.hover.amount) * rate;
if (Math.abs(this.hover.goal - this.hover.amount) < 0.002) this.hover.amount = this.hover.goal;
// A leaf that is no longer the hovered top page (it was just turned, or grabbed) lets its
// corner go smoothly: the lift carries into the turn and settles, it does not snap flat.
const release = Math.exp(-dt * 12);
for (const leaf of this.leaves) {
const hovered = leaf.index === hoverIndex && !this.tweens.has(leaf.index) && !this.drag;
let peel = hovered ? this.hover.amount : leaf.peel * release;
if (!hovered && peel < 0.002) peel = 0;
if (leaf.peel !== peel) {
leaf.peel = peel;
moving = true;
}
}
if (this.hover.amount !== this.hover.goal) moving = true;
if (this.drag) moving = true;
for (const leaf of this.leaves) leaf.pose();
// Closed, the brochure lies a little askew with its cover centred; open, it squares up.
const first = this.leaves[0]!.t;
const last = this.leaves[this.count - 1]!.t;
this.group.position.x = -(PAGE_W / 2) * (1 - first) + (PAGE_W / 2) * last;
this.group.rotation.y = 0.055 * (1 - first) - 0.035 * last;
return moving;
}
/**
* The leaf on top of one side's stack as it physically is: on the right the lowest leaf not yet
* past the middle, on the left the highest that is. It is -1 for an empty side, and the leaf in
* the air is returned too (callers skip it, which also keeps the leaf under it still).
*/
private topLeaf(side: "left" | "right") {
if (side === "right") return this.leaves.findIndex((leaf) => leaf.t < 0.5);
for (let index = this.leaves.length - 1; index >= 0; index--) if (this.leaves[index]!.t >= 0.5) return index;
return -1;
}
/** Total height of the stack at its tallest, for picking the plane the reader touches. */
get height() {
return this.leaves.reduce((sum, leaf) => sum + leaf.thickness, 0);
}
dispose() {
for (const leaf of this.leaves) leaf.dispose();
}
}components/vibebox/brochure/brochure.client.tsx
/**
* VibeBox Motion · Brochure
* Copyright (c) 2026 VibeBox. Licensed under the VibeBox Motion Free License:
* https://motion.vibeboxph.com/license
* Use it in unlimited personal, commercial and client projects.
* Do not sell or redistribute it as a component, kit, template or builder.
*/
"use client";
import { useEffect, useRef, useState, type KeyboardEvent } from "react";
import { canRenderLive, prefersReducedMotion } from "@/lib/vibebox/engine/core";
import { cn } from "@/lib/utils";
import type { BrochureHandle, BrochurePage } from "./brochure.scene";
export type { BrochurePage } from "./brochure.scene";
export type BrochureProps = {
/** Every side of every sheet, in reading order: the front cover, then page 2, 3 and so on. */
pages: readonly BrochurePage[];
/** Paper, ink, the cover's accent shape and the desk. */
colors?: { paper?: string; ink?: string; accent?: string; desk?: string };
/** Seconds one sheet takes to turn. */
turnSeconds?: number;
/** Accessible name. */
label?: string;
className?: string;
};
/* The same pages as plain, readable content: the poster, the no-WebGL view and what a
screen reader gets. */
function Pages({ pages, readable = false }: { pages: readonly BrochurePage[]; readable?: boolean }) {
// The poster copy never scrolls (a scroll area would have to be focusable); the readable copy
// is plain list content for screen readers.
return (
<ol className={readable ? "list-none" : "grid h-full list-none grid-cols-2 gap-px overflow-hidden bg-border/60 sm:grid-cols-3"}>
{pages.map((page, index) => (
<li key={index} className="flex flex-col gap-1.5 bg-card p-4 text-card-foreground" style={{ background: page.color, color: page.ink }}>
{page.eyebrow && <span className="font-mono text-[0.62rem] tracking-wide uppercase opacity-70">{page.eyebrow}</span>}
{page.title && <span className="text-[1rem] leading-tight font-semibold">{page.title}</span>}
{page.text && <span className="line-clamp-4 text-[0.78rem] opacity-80">{page.text}</span>}
</li>
))}
</ol>
);
}
export function Brochure({ pages, colors, turnSeconds, label = "A brochure: click a page, drag its corner or use the arrow keys to turn it", className }: BrochureProps) {
const hostRef = useRef<HTMLDivElement>(null);
const handleRef = useRef<BrochureHandle | null>(null);
const [live, setLive] = useState(false);
const [spread, setSpread] = useState(0);
const [spreads, setSpreads] = useState(Math.ceil(pages.length / 2) + 1);
const pagesKey = JSON.stringify(pages);
const colorsKey = JSON.stringify(colors ?? {});
useEffect(() => {
const host = hostRef.current;
if (!host || !canRenderLive()) return;
let cancelled = false;
let started = false;
let visible = false;
let releaseTimer = 0;
const start = async () => {
const scene = await import("./brochure.scene");
if (cancelled) return;
const handle = scene.createBrochureScene(host, {
pages,
colors,
turnSeconds,
still: prefersReducedMotion(),
onSpread: (value, total) => {
setSpread(value);
setSpreads(total + 1);
},
onReady: () => setLive(true),
});
handleRef.current = handle;
setSpreads(handle.spreads);
handle.setActive(visible);
};
const observer = new IntersectionObserver(
([entry]) => {
visible = Boolean(entry?.isIntersecting);
window.clearTimeout(releaseTimer);
if (visible && !started) {
started = true;
void start();
} else if (visible && !handleRef.current && started) {
// It was released while far away; build it again.
void start();
}
handleRef.current?.setActive(visible);
// Far off screen for a while: give the GPU context back.
if (!visible) {
releaseTimer = window.setTimeout(() => {
handleRef.current?.dispose();
handleRef.current = null;
setLive(false);
setSpread(0);
}, 8000);
}
},
{ rootMargin: "300px" },
);
observer.observe(host);
return () => {
cancelled = true;
window.clearTimeout(releaseTimer);
observer.disconnect();
handleRef.current?.dispose();
handleRef.current = null;
setLive(false);
};
// pages and colours are compared by value through their keys.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pagesKey, colorsKey, turnSeconds]);
const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
if (event.key === "ArrowRight") {
event.preventDefault();
handleRef.current?.next();
} else if (event.key === "ArrowLeft") {
event.preventDefault();
handleRef.current?.prev();
}
};
return (
<div data-vb="brochure" data-live={live ? "true" : "false"} data-spread={spread} className={cn("relative isolate overflow-hidden", className)}>
<div aria-hidden="true" className={cn("absolute inset-0 transition-opacity duration-500 ease-expo-out", live ? "opacity-0" : "opacity-100")}>
<Pages pages={pages} />
</div>
<div className="sr-only">
<Pages pages={pages} readable />
</div>
<div
ref={hostRef}
role="group"
aria-label={label}
tabIndex={0}
onKeyDown={onKeyDown}
className="absolute inset-0 focus-visible:outline-2 focus-visible:outline-offset-[-4px] focus-visible:outline-ring"
/>
{live && (
<div className="pointer-events-none absolute inset-x-0 bottom-3 flex items-center justify-center gap-3">
<button
type="button"
onClick={() => handleRef.current?.prev()}
disabled={spread === 0}
aria-label="Previous page"
className="pointer-events-auto grid size-9 place-items-center rounded-full border border-border-strong bg-background/70 text-foreground backdrop-blur transition-opacity duration-150 hover:bg-accent focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring disabled:opacity-35"
>
<svg viewBox="0 0 16 16" aria-hidden="true" className="size-4" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round">
<path d="M13.5 8h-11M7 3.5 2.5 8 7 12.5" />
</svg>
</button>
<span aria-live="polite" className="rounded-full bg-background/70 px-3 py-1 font-mono text-caption text-muted-foreground backdrop-blur">
{spread === 0 ? "Cover" : spread >= spreads - 1 ? "Back cover" : `Pages ${spread * 2} and ${spread * 2 + 1}`}
</span>
<button
type="button"
onClick={() => handleRef.current?.next()}
disabled={spread >= spreads - 1}
aria-label="Next page"
className="pointer-events-auto grid size-9 place-items-center rounded-full border border-border-strong bg-background/70 text-foreground backdrop-blur transition-opacity duration-150 hover:bg-accent focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring disabled:opacity-35"
>
<svg viewBox="0 0 16 16" aria-hidden="true" className="size-4" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round">
<path d="M2.5 8h11M9 3.5 13.5 8 9 12.5" />
</svg>
</button>
</div>
)}
</div>
);
}components/vibebox/brochure/brochure.scene.ts
/**
* VibeBox Motion · Brochure
* Copyright (c) 2026 VibeBox. Licensed under the VibeBox Motion Free License:
* https://motion.vibeboxph.com/license
* Use it in unlimited personal, commercial and client projects.
* Do not sell or redistribute it as a component, kit, template or builder.
*/
import * as THREE from "three";
import { Book, Leaf, PAGE_H, PAGE_W, type LeafMaterials } from "./brochure.book";
/*
* The scene around the book: a renderer that draws only while something moves, a desk that takes
* its shadow, page artwork painted with the 2D canvas from props, and the pointer: hover lifts
* the corner of the page under it, a drag turns it with the hand, a click turns it by itself.
*/
export type BrochurePage = {
/** A small label above the title. */
eyebrow?: string;
title?: string;
text?: string;
/** An image for the top of the page (same origin or CORS-enabled). */
image?: string;
/** Page colour; by default the paper colour. */
color?: string;
/** Text colour; by default the ink colour. */
ink?: string;
/** A cover is set large, with the title across the middle. */
kind?: "cover" | "page";
};
export type BrochureOptions = {
pages: readonly BrochurePage[];
colors?: { paper?: string; ink?: string; accent?: string; desk?: string };
/** Seconds one leaf takes to turn. */
turnSeconds?: number;
/** Draws one frame and never animates (reduced motion). */
still?: boolean;
onSpread?: (spread: number, spreads: number) => void;
onReady?: () => void;
};
export type BrochureHandle = {
next(): void;
prev(): void;
goTo(spread: number): void;
resize(): void;
setActive(active: boolean): void;
dispose(): void;
readonly spreads: number;
};
const TEX_W = 700;
const TEX_H = Math.round((TEX_W * PAGE_H) / PAGE_W);
const clamp01 = (value: number) => Math.min(1, Math.max(0, value));
function fonts() {
const style = getComputedStyle(document.body);
const pick = (name: string, fallback: string) => style.getPropertyValue(name).trim() || fallback;
return { display: pick("--font-display", "system-ui, sans-serif"), serif: pick("--font-serif", "Georgia, serif"), mono: pick("--font-mono", "ui-monospace, monospace") };
}
function wrap(ctx: CanvasRenderingContext2D, text: string, maxWidth: number): string[] {
const lines: string[] = [];
let line = "";
for (const word of text.split(/\s+/)) {
const test = line ? `${line} ${word}` : word;
if (ctx.measureText(test).width > maxWidth && line) {
lines.push(line);
line = word;
} else line = test;
}
if (line) lines.push(line);
return lines;
}
function paintPage(page: BrochurePage | undefined, index: number, colors: Required<NonNullable<BrochureOptions["colors"]>>, family: ReturnType<typeof fonts>, image: HTMLImageElement | null) {
const canvas = document.createElement("canvas");
canvas.width = TEX_W;
canvas.height = TEX_H;
const ctx = canvas.getContext("2d")!;
const paper = page?.color ?? colors.paper;
const ink = page?.ink ?? colors.ink;
ctx.fillStyle = paper;
ctx.fillRect(0, 0, TEX_W, TEX_H);
// A faint grain, so a flat colour reads as paper.
ctx.fillStyle = "rgba(0,0,0,0.018)";
for (let i = 0; i < 700; i++) ctx.fillRect(Math.random() * TEX_W, Math.random() * TEX_H, 1 + Math.random() * 2, 1);
if (!page) return canvas;
const margin = 56;
const cover = page.kind === "cover";
if (cover) {
ctx.fillStyle = colors.accent;
ctx.beginPath();
ctx.arc(TEX_W * 0.7, TEX_H * 0.34, TEX_W * 0.3, 0, Math.PI * 2);
ctx.fill();
}
if (image && !cover) {
const h = TEX_H * 0.36;
const scale = Math.max((TEX_W - margin * 2) / image.naturalWidth, h / image.naturalHeight);
ctx.save();
ctx.beginPath();
ctx.rect(margin, margin, TEX_W - margin * 2, h);
ctx.clip();
ctx.drawImage(image, margin + (TEX_W - margin * 2 - image.naturalWidth * scale) / 2, margin + (h - image.naturalHeight * scale) / 2, image.naturalWidth * scale, image.naturalHeight * scale);
ctx.restore();
}
let y = image && !cover ? margin + TEX_H * 0.36 + 56 : margin + 28;
ctx.fillStyle = ink;
ctx.textBaseline = "alphabetic";
if (page.eyebrow) {
ctx.font = `500 22px ${family.mono}`;
(ctx as CanvasRenderingContext2D & { letterSpacing: string }).letterSpacing = "4px";
ctx.fillText(page.eyebrow.toUpperCase(), margin, y);
(ctx as CanvasRenderingContext2D & { letterSpacing: string }).letterSpacing = "0px";
y += 22;
}
if (page.title) {
let size = cover ? 112 : 70;
const maxWidth = TEX_W - margin * 2;
ctx.font = `800 ${size}px ${family.display}`;
let lines = wrap(ctx, page.title, maxWidth);
while (lines.length > (cover ? 4 : 3) && size > 36) {
size -= 6;
ctx.font = `800 ${size}px ${family.display}`;
lines = wrap(ctx, page.title, maxWidth);
}
if (cover) y = TEX_H * 0.5;
else y += size * 0.86;
for (const line of lines) {
ctx.fillText(line, margin, y);
y += size * 1.02;
}
y += cover ? 22 : 30;
}
if (page.text) {
ctx.font = `400 33px ${family.serif}`;
ctx.globalAlpha = 0.82;
for (const line of wrap(ctx, page.text, TEX_W - margin * 2).slice(0, 12)) {
ctx.fillText(line, margin, y);
y += 47;
}
ctx.globalAlpha = 1;
}
// The folio, centred at the foot of a content page.
if (!cover) {
ctx.font = `500 20px ${family.mono}`;
ctx.globalAlpha = 0.5;
ctx.textAlign = index % 2 === 0 ? "left" : "right";
ctx.fillText(String(index + 1).padStart(2, "0"), index % 2 === 0 ? margin : TEX_W - margin, TEX_H - 40);
ctx.globalAlpha = 1;
ctx.textAlign = "left";
}
return canvas;
}
export function createBrochureScene(host: HTMLElement, options: BrochureOptions): BrochureHandle {
const colors = { paper: "#f6f1e7", ink: "#14110d", accent: "#e8742a", desk: "#1a1714", ...options.colors };
const still = Boolean(options.still);
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true, powerPreference: "high-performance" });
renderer.setClearColor(0x000000, 0);
renderer.outputColorSpace = THREE.SRGBColorSpace;
renderer.toneMapping = THREE.NeutralToneMapping;
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFShadowMap;
const canvas = renderer.domElement;
canvas.style.cssText = "display:block;width:100%;height:100%;touch-action:pan-y;";
canvas.setAttribute("aria-hidden", "true");
host.appendChild(canvas);
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(26, 1, 0.05, 5);
scene.add(new THREE.HemisphereLight(0xfff8ee, 0x30261a, 1.7));
const key = new THREE.DirectionalLight(0xfff1dc, 3.1);
key.position.set(-0.35, 0.9, 0.45);
key.castShadow = true;
key.shadow.mapSize.set(1024, 1024);
key.shadow.camera.near = 0.2;
key.shadow.camera.far = 2;
key.shadow.camera.left = -0.4;
key.shadow.camera.right = 0.4;
key.shadow.camera.top = 0.4;
key.shadow.camera.bottom = -0.4;
key.shadow.bias = -0.0004;
key.shadow.radius = 6;
scene.add(key);
const desk = new THREE.Mesh(new THREE.PlaneGeometry(2.2, 1.6), new THREE.ShadowMaterial({ opacity: 0.38 }));
desk.rotation.x = -Math.PI / 2;
desk.position.y = -0.0006;
desk.receiveShadow = true;
scene.add(desk);
// Leaves from pages: a leaf's front is page 2i and its back page 2i + 1.
const family = fonts();
const leafCount = Math.max(1, Math.ceil(options.pages.length / 2));
const images = new Map<string, HTMLImageElement>();
const textures: THREE.CanvasTexture[] = [];
const materials: LeafMaterials[] = [];
const paint = (page: BrochurePage | undefined, index: number) => {
const image = page?.image ? (images.get(page.image) ?? null) : null;
return paintPage(page, index, colors, family, image);
};
const makeTexture = (canvasEl: HTMLCanvasElement, turned = false) => {
const map = new THREE.CanvasTexture(canvasEl);
map.colorSpace = THREE.SRGBColorSpace;
map.anisotropy = 8;
// A sheet's back is the underside of its box: its artwork runs the other way round.
if (turned) {
map.center.set(0.5, 0.5);
map.rotation = Math.PI;
}
textures.push(map);
return map;
};
const edgeMat = new THREE.MeshStandardMaterial({ color: new THREE.Color(colors.paper).multiplyScalar(0.9), roughness: 0.9 });
const pageTextures: { front: THREE.CanvasTexture; back: THREE.CanvasTexture; indexFront: number }[] = [];
const leaves: Leaf[] = [];
for (let i = 0; i < leafCount; i++) {
const front = makeTexture(paint(options.pages[i * 2], i * 2));
const back = makeTexture(paint(options.pages[i * 2 + 1], i * 2 + 1), true);
pageTextures.push({ front, back, indexFront: i * 2 });
const material: LeafMaterials = {
front: new THREE.MeshStandardMaterial({ map: front, roughness: 0.82 }),
back: new THREE.MeshStandardMaterial({ map: back, roughness: 0.82 }),
edge: edgeMat,
};
materials.push(material);
const cover = i === 0 || i === leafCount - 1;
leaves.push(new Leaf(i, cover ? 0.0022 : 0.0009, cover ? 0.9 : 0.62, material));
}
// Images arrive later: repaint the pages that wait for them.
for (const page of options.pages) {
if (!page.image || images.has(page.image)) continue;
const image = new Image();
image.crossOrigin = "anonymous";
image.onload = () => {
images.set(page.image!, image);
options.pages.forEach((candidate, index) => {
if (candidate.image !== page.image) return;
const entry = pageTextures[Math.floor(index / 2)];
if (!entry) return;
const target = index % 2 === 0 ? entry.front : entry.back;
const next = paint(candidate, index);
target.image = next;
target.needsUpdate = true;
});
requestRender();
};
image.src = page.image;
}
const book = new Book(leaves, { turnSeconds: options.turnSeconds ?? 0.95 });
book.group.traverse((node) => {
if ((node as THREE.Mesh).isMesh) {
node.castShadow = true;
node.receiveShadow = true;
}
});
scene.add(book.group);
// ---- layout ----
let width = 1;
let height = 1;
let baseDistance = 1;
let zoomed = 0;
const TILT = 0.95; // radians above the desk
const CLOSED_ZOOM = 0.14;
/** 1 with the front or back cover shut (the book is shifted to centre it), 0 with a spread open. */
const closedness = () => Math.min(1, Math.abs(book.group.position.x) / (PAGE_W / 2));
const fit = () => {
width = Math.max(1, host.clientWidth);
height = Math.max(1, host.clientHeight);
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, width < 640 ? 1.5 : 1.75));
renderer.setSize(width, height, false);
camera.aspect = width / height;
const vfov = THREE.MathUtils.degToRad(camera.fov);
const hfov = 2 * Math.atan(Math.tan(vfov / 2) * camera.aspect);
const forWidth = (PAGE_W * 2 * 1.1) / (2 * Math.tan(hfov / 2));
const forHeight = (PAGE_H * 1.45) / (2 * Math.tan(vfov / 2));
baseDistance = Math.max(forWidth, forHeight);
zoomed = closedness();
place(1 - CLOSED_ZOOM * zoomed);
camera.updateProjectionMatrix();
};
/** Pulls the camera in as the brochure closes: a single cover needs less of the frame. */
const place = (scale: number) => {
const distance = baseDistance * scale;
camera.position.set(0, Math.sin(TILT) * distance, Math.cos(TILT) * distance + 0.02);
camera.lookAt(0, 0, 0);
};
// ---- drawing on demand ----
let raf = 0;
let last = 0;
let active = true;
let dirty = true;
const now = () => performance.now() / 1000;
const frame = () => {
raf = 0;
const t = now();
const dt = Math.min(0.05, t - last || 0.016);
last = t;
const moving = book.update(t, dt);
const closed = closedness();
if (Math.abs(closed - zoomed) > 0.001) {
zoomed = closed;
place(1 - CLOSED_ZOOM * zoomed);
}
renderer.render(scene, camera);
if (options.onReady) {
options.onReady();
options.onReady = undefined;
}
dirty = false;
if ((moving || book.busy) && active) raf = requestAnimationFrame(frame);
};
function requestRender() {
dirty = true;
if (!raf && active) {
last = now();
raf = requestAnimationFrame(frame);
}
}
// ---- the pointer ----
const plane = new THREE.Plane(new THREE.Vector3(0, 1, 0), -0.002);
const ray = new THREE.Raycaster();
const hit = new THREE.Vector3();
const local = (event: PointerEvent) => {
const rect = canvas.getBoundingClientRect();
const ndc = new THREE.Vector2(((event.clientX - rect.left) / rect.width) * 2 - 1, -(((event.clientY - rect.top) / rect.height) * 2 - 1));
ray.setFromCamera(ndc, camera);
if (!ray.ray.intersectPlane(plane, hit)) return null;
return { x: hit.x - book.group.position.x, z: hit.z };
};
let press: { side: "left" | "right"; startX: number; startAt: number; moved: boolean; lastX: number; lastAt: number; velocity: number } | null = null;
const edgeSide = (x: number): "left" | "right" | null => {
const spineLeft = book.target > 0;
const spineRight = book.target < book.count;
if (x > PAGE_W * 0.42 && x < PAGE_W * 1.05 && spineRight) return "right";
if (x < -PAGE_W * 0.42 && x > -PAGE_W * 1.05 && spineLeft) return "left";
return null;
};
const pageSide = (x: number): "left" | "right" | null => {
if (x > 0 && x < PAGE_W * 1.1 && book.target < book.count) return "right";
if (x < 0 && x > -PAGE_W * 1.1 && book.target > 0) return "left";
return null;
};
const onMove = (event: PointerEvent) => {
const p = local(event);
if (!p) return;
if (press) {
const dx = Math.abs(event.clientX - press.startX);
if (dx > 6) press.moved = true;
if (press.moved && book.dragging) {
const progress = press.side === "right" ? clamp01((PAGE_W - p.x) / (PAGE_W * 2)) : clamp01((p.x + PAGE_W) / (PAGE_W * 2));
book.dragTo(progress);
const t = now();
press.velocity = (event.clientX - press.lastX) / Math.max(0.001, t - press.lastAt) / 1000;
press.lastX = event.clientX;
press.lastAt = t;
}
requestRender();
return;
}
const side = edgeSide(p.x);
canvas.style.cursor = pageSide(p.x) ? (side ? "grab" : "pointer") : "default";
book.setHover(side);
requestRender();
};
const onDown = (event: PointerEvent) => {
if (still) return;
const p = local(event);
if (!p) return;
const side = pageSide(p.x);
if (!side) return;
press = { side, startX: event.clientX, startAt: now(), moved: false, lastX: event.clientX, lastAt: now(), velocity: 0 };
canvas.setPointerCapture(event.pointerId);
if (edgeSide(p.x) === side) book.beginDrag(side, clamp01(0.5 + p.z / PAGE_H) * 2 - 1);
canvas.style.cursor = "grabbing";
requestRender();
};
const onUp = (event: PointerEvent) => {
const state = press;
press = null;
if (!state) return;
if (canvas.hasPointerCapture(event.pointerId)) canvas.releasePointerCapture(event.pointerId);
if (book.dragging) {
if (state.moved) {
const towardSpine = state.side === "right" ? -state.velocity : state.velocity;
book.endDrag(now(), Math.max(0, towardSpine));
} else {
// A press without a drag is a click: put the page back, then turn it the normal way.
book.endDrag(now(), 0);
book.goTo(book.target + (state.side === "right" ? 1 : -1), now());
}
} else if (!state.moved) {
book.goTo(book.target + (state.side === "right" ? 1 : -1), now());
}
requestRender();
};
const onLeave = () => {
book.setHover(null);
requestRender();
};
book.onTurn = () => {
options.onSpread?.(book.target, leafCount);
requestRender();
};
const resize = new ResizeObserver(() => {
fit();
requestRender();
});
fit();
// Open the cover a crack on first paint so it reads as a book, unless motion is still.
if (!still) {
canvas.addEventListener("pointermove", onMove);
canvas.addEventListener("pointerdown", onDown);
canvas.addEventListener("pointerup", onUp);
canvas.addEventListener("pointercancel", onUp);
canvas.addEventListener("pointerleave", onLeave);
}
resize.observe(host);
requestRender();
const handle: BrochureHandle = {
spreads: leafCount + 1,
next() {
book.goTo(book.target + 1, now());
requestRender();
},
prev() {
book.goTo(book.target - 1, now());
requestRender();
},
goTo(spread) {
book.goTo(spread, now());
requestRender();
},
resize() {
fit();
requestRender();
},
setActive(next) {
active = next;
if (next) requestRender();
else {
cancelAnimationFrame(raf);
raf = 0;
}
},
dispose() {
cancelAnimationFrame(raf);
resize.disconnect();
canvas.removeEventListener("pointermove", onMove);
canvas.removeEventListener("pointerdown", onDown);
canvas.removeEventListener("pointerup", onUp);
canvas.removeEventListener("pointercancel", onUp);
canvas.removeEventListener("pointerleave", onLeave);
book.dispose();
textures.forEach((map) => map.dispose());
materials.forEach((material) => {
(material.front as THREE.Material).dispose();
(material.back as THREE.Material).dispose();
});
edgeMat.dispose();
desk.geometry.dispose();
(desk.material as THREE.Material).dispose();
renderer.dispose();
canvas.remove();
},
};
void dirty;
return handle;
}components/vibebox/brochure/index.tsx
/**
* VibeBox Motion · Brochure
* Copyright (c) 2026 VibeBox. Licensed under the VibeBox Motion Free License:
* https://motion.vibeboxph.com/license
* Use it in unlimited personal, commercial and client projects.
* Do not sell or redistribute it as a component, kit, template or builder.
*/
export { Brochure, type BrochurePage, type BrochureProps } from "./brochure.client";Props
| Prop | Type | Default | Description |
|---|---|---|---|
| pages * | BrochurePage[] | — | Every side of every sheet, in reading order: { eyebrow?, title?, text?, image?, color?, ink?, kind? }. The first is the front cover and the last the back. |
| colors | { paper?: string; ink?: string; accent?: string; desk?: string } | — | Paper, ink, the cover's accent shape and the desk. |
| turnSeconds | number | 0.95 | Seconds one sheet takes to turn. |
| label | string | — | Accessible name of the 3D group. |
| className | string | — | Merged with cn(); give it a size (about 16 / 10) and a background. |
Accessibility and motion
- Keyboard
- The brochure is focusable: Left and Right arrows turn a page; the previous and next buttons are real buttons.
- Screen readers
- The pages are rendered once as a plain ordered list for screen readers; the 3D canvas is hidden from them.
- Touch
- Drag a page's outer edge to turn it; tap a page to turn it; scrolling stays available above and below the book.
- Reduced motion
- The book draws one frame and does not animate; the arrow buttons and keys still turn pages, without motion.
Edge cases and limits
- Pages are painted at 700 px wide: long paragraphs are cut at fourteen lines.
- An odd number of pages leaves the last back blank paper.
- Images need to be same origin or served with CORS headers.
Related

Ticket
FreeA 3D paper ticket with a foil spine that leans to the pointer and tears at the stub when you hold over it.
3D · three.js

Coin
FreeA struck coin with a reeded edge, your mark and words on its face; hold the pointer on it and it turns over.
3D · three.js

Dotted Globe
FreeA globe of dots with pulsing markers and arcs; drag to turn it, choose a place to spin to it.
3D · three.js