@vysmo/flipbook Component DOM Built on Transitions

Flip Book for the modern web.

WebGL flipbook around the page-curl mesh transition. Drop-in component or headless API. Click, drag-scrub mid-flip, keyboard nav. Drag-scrub is the ecosystem-native hook.

  • ~3 KB gzipped
  • 0 runtime deps
  • WebGL2 renderer
$ pnpm add @vysmo/flipbook View on GitHub

Install

One package. @vysmo/flipbook composes @vysmo/transitions (page-curl mesh shader), @vysmo/animations (commit / revert tweens), and @vysmo/easings at the workspace level — you only install the one.

pnpm add @vysmo/flipbook npm install @vysmo/flipbook yarn add @vysmo/flipbook

Zero runtime dependencies, SSR-safe at module load, tree-shakable to the byte. The headless core has no opinions about your UI — drop it into a container and style the chrome yourself.

Quick start

Drop a container in the DOM, hand the flipbook a list of page sources, and you have a working flipbook with click halves, drag-scrub, and keyboard nav wired by default.

import { createFlipbook } from "@vysmo/flipbook";

// 1. A container with measurable dimensions. The flipbook mounts its own
//    canvas inside; size the container via CSS — the canvas fills 100%.
const container = document.querySelector<HTMLDivElement>("#book")!;

// 2. Page sources: URL strings (decoded as images) or HTMLImageElement /
//    HTMLCanvasElement (used as-is). Order matters — index 0 is the cover.
const flip = createFlipbook({
  container,
  pages: [
    "/page-01.jpg",
    "/page-02.jpg",
    "/page-03.jpg",
    "/page-04.jpg",
  ],
  axis: "horizontal", // "vertical" for a wall-calendar peel
  loop: true,         // wrap last → first
});

// 3. Programmatic nav (click halves, drag corners, and arrow keys are
//    all wired automatically — these are for your own UI chrome).
await flip.ready;
flip.next();
flip.goTo(2);
flip.on("change", (current) => console.log("now on page", current));

Three pieces. The container holds the canvas — size it via CSS. Page sources are URL strings (decoded to images) or DOM elements you've already prepared (use a canvas if you need rich DOM pages: pre-render with html2canvas / OffscreenCanvas). The handle is your imperative API: next, prev, goTo, seek, play / pause, plus event subscriptions. All built-in interactions can be disabled if you'd rather build your own controls (see Recipes).

Using with React

Drop-in <Flipbook> component that mounts the flipbook into its container with all the same options (axis, loop, autoplay, callbacks). useFlipbook(ref, options) hook for imperative control (next / prev / goTo / seek).

Install pnpm add @vysmo/flipbook @vysmo/flipbook-react
import { Flipbook } from "@vysmo/flipbook-react";

export function PortfolioBook() {
  return (
    <Flipbook
      pages={["/01.jpg", "/02.jpg", "/03.jpg", "/04.jpg"]}
      style={{ width: 600, height: 800 }}
    />
  );
}
Full props + advanced patterns on npm

Concepts

A quick mental model before the API ref. Skip if you're already comfortable with the playground.

Headless core, optional chrome

The library mounts a WebGL canvas inside your container and wires click / drag / keyboard. It renders nothing outside that canvas — no page indicator, no buttons, no toolbars. Bring your own UI; we just expose the handle so you can wire it up.

Drag-scrub mid-flip

Pointer-drag in the flip direction peels the page mid-curl and maps your gesture directly to the page-curl shader's progress. Release past dragCommitThreshold (default 0.5) and the flip commits; below, it reverts. This is the ecosystem-native hook — impossible without the underlying mesh transition.

Axis: horizontal vs vertical

"horizontal" peels the right edge leftward like an English book (pinned spine on the left). "vertical" peels the bottom edge upward like a wall calendar (pinned binding at the top). The tilt option adds a hinge angle on top of the axis baseline so the curl line isn't a clean vertical / horizontal sweep.

Autoplay vs Loop

Loop wraps last → first when you call next() (or vice versa for prev). Autoplay calls next() on a timer. They compose: autoplay + loop is a never-ending rotator; autoplay alone stops at the last page. play() / pause() let you control the autoplay timer at runtime.

seek() for scroll-driven flipbooks

flip.seek(progress) drives the curl from current to the next page at any progress in [0, 1]. Pipe scroll progress straight in for cinematic page reveals tied to scroll position. See the Scroll-driven recipe below.

Page sources are decoded once

URL strings are fetched and image.decode()'d at construction time; the ready promise resolves when every source is ready. Calling next() / prev() before ready is safe — they queue. Pre-decoded sources (HTMLImageElement / HTMLCanvasElement) resolve instantly.

API reference

Four things to know. Everything else is composition.

createFlipbook(options)

Constructs a flipbook in the given container and returns a handle. All options except container and pages have sensible defaults. Mutating the returned handle is the only way to drive the flipbook — there's no React-style re-render-with-new-options pattern.

createFlipbook({
  container: HTMLElement,
  pages: readonly PageSource[],
  initialPage?: number,        // default 0
  axis?: "horizontal" | "vertical", // default "horizontal"
  radius?: number,             // curl radius (clip-space). Default 0.35
  tilt?: number,               // hinge tilt (radians). Default 0.12
  backColor?: [r, g, b],       // page-back colour. Default cream
  flipDuration?: number,       // tween duration (ms). Default 900
  ease?: EasingFn,             // default cubicInOut from @vysmo/easings
  loop?: boolean,              // wrap at the ends. Default false
  clickNavigation?: boolean,   // click halves to flip. Default true
  dragNavigation?: boolean,    // pointer-drag scrub. Default true
  dragCommitThreshold?: number,// release-past commits the flip. Default 0.5
  keyboardNavigation?: boolean,// arrows, Home, End. Default true
  autoplay?: boolean | { intervalMs: number }, // default off
  ariaLabel?: string,          // default "Flipbook"
}): FlipbookHandle

FlipbookHandle

The return value of createFlipbook. Reactive state via getters (current, length, isFlipping, isPlaying), imperative navigation, scroll-driven seek, autoplay control, event subscriptions. destroy() tears down the WebGL context, removes event listeners, and detaches the canvas.

interface FlipbookHandle {
  // Reactive state (getters)
  readonly current: number;
  readonly length: number;
  readonly isFlipping: boolean;
  readonly isPlaying: boolean;

  // Resolves once every page source has decoded. Safe to call methods
  // before this resolves — they queue until the first frame is ready.
  readonly ready: Promise<void>;

  // Imperative navigation
  next(): Promise<void>;
  prev(): Promise<void>;
  goTo(index: number, options?: { instant?: boolean }): Promise<void>;

  // Externally-driven progress (scroll, scrub, custom gestures).
  // 0 = idle on `current`; 1 = commit to next; mid-curl in between.
  seek(progress: number): void;

  // Autoplay control. play() and pause() are no-ops if already in state.
  play(): void;
  pause(): void;

  // Events
  on(event: "change",    cb: (current: number, previous: number) => void): () => void;
  on(event: "flipstart", cb: (from: number, to: number) => void): () => void;
  on(event: "flipend",   cb: (from: number, to: number) => void): () => void;

  destroy(): void;
}

PageSource

Anything you can hand to a page slot. Strings are URLs; DOM element variants are used as-is. Rich-DOM pages are out of scope — pre-render to a canvas if you need text overlays, dynamic content, or HTML.

// Anything you can hand to a flipbook page slot.
type PageSource =
  | string                 // URL — decoded as an image at construction time
  | HTMLImageElement       // already-decoded image, used as-is
  | HTMLCanvasElement;     // pre-rendered canvas (for rich DOM pages)

Events

flip.on(event, cb) returns an unsubscriber. Call it to detach without tearing down the whole flipbook.

  • change(current, previous) — emitted after a commit. current is the new page index.
  • flipstart(from, to) — emitted when a flip begins. Fires after click / drag-commit / autoplay tick.
  • flipend(from, to) — emitted when the curl tween settles. change fires fractionally before flipend.

Recipes

Common patterns. Each one stands alone — copy, adapt, ship.

Auto-rotating hero book

A landing-page book that flips on its own, pauses on hover, resumes on leave.

const flip = createFlipbook({
  container,
  pages: heroPhotos,
  loop: true,
  autoplay: { intervalMs: 5000 },  // one new flip every 5 seconds
});

// Pause when the user hovers the book; resume when they leave.
container.addEventListener("pointerenter", () => flip.pause());
container.addEventListener("pointerleave", () => flip.play());

Scroll-driven flipbook

Pipe scroll progress straight into seek so the page peels open as the visitor scrolls past. Disable dragNavigation so the user's drag doesn't conflict with the scroll-driven curl.

import { createFlipbook } from "@vysmo/flipbook";
import { createScrollProgress } from "@vysmo/scroll";

const flip = createFlipbook({ container, pages, dragNavigation: false });

// Drive the curl directly from scroll progress. seek(p) holds the page
// mid-curl at progress p; reaching 1 commits, 0 reverts.
createScrollProgress({
  element: container,
  onProgress: (p) => flip.seek(p),
});

Custom controls

Disable the built-in click / drag / keyboard nav, build your own prev / next / counter UI, and wire it through the handle.

const flip = createFlipbook({
  container,
  pages,
  // Disable built-in nav; build your own.
  clickNavigation: false,
  dragNavigation: false,
  keyboardNavigation: false,
});

document.querySelector("#prev")!.addEventListener("click", () => flip.prev());
document.querySelector("#next")!.addEventListener("click", () => flip.next());

// Mirror flipbook state into your UI.
flip.on("change", (current) => {
  document.querySelector("#counter")!.textContent =
    `${current + 1} / ${flip.length}`;
});

FAQ & gotchas

The handful of things that catch people the first time they ship a flipbook.

Why isn’t my flipbook rendering?

The container needs measurable dimensions. Set width + height (or width + aspect-ratio) via CSS — the canvas fills 100% of both. A container with display: inline, zero height, or no laid-out width will silently show nothing. Verify with container.getBoundingClientRect() in the console: width and height should both be > 0 before you call createFlipbook.

Does it respect prefers-reduced-motion?

Not automatically — the curl is the whole point of the library, and we don’t want to second-guess your design decision. If you want to honor user preference, detect with matchMedia and call goTo with instant: true so pages snap rather than curl:

if (matchMedia("(prefers-reduced-motion: reduce)").matches) {
  // Snap to the target instead of curling.
  flip.goTo(target, { instant: true });
} else {
  flip.next();
}

Can I have multiple flipbooks on one page?

Yes. Each createFlipbook call creates an isolated instance with its own WebGL context. Browsers cap WebGL contexts per page (~16 in Chrome) — well above what you’d reasonably ship. If you ever need many small flipbooks on one page, consider whether they really all need to be live or could render lazily on intersection.

How do I clean up properly?

Call flip.destroy(). Disposes the WebGL context, removes pointer / keyboard / resize listeners, clears any autoplay timer, and detaches the canvas from the container. Always run it on unmount in component frameworks — otherwise you leak GPU memory and event handlers across re-renders.

Is there a React / Vue / Svelte adapter?

The vanilla API is the surface today. It's small enough that a framework hook is a few lines:

// React
import { useEffect, useRef } from "react";
import { createFlipbook } from "@vysmo/flipbook";

function Book({ pages }) {
  const ref = useRef<HTMLDivElement>(null);
  useEffect(() => {
    const flip = createFlipbook({ container: ref.current!, pages });
    return () => flip.destroy();
  }, [pages]);
  return <div ref={ref} style={{ width: "100%", aspectRatio: "16/9" }} />;
}

Mobile / touch?

Touch is supported through pointer events — drag-scrub works on mobile out of the box. The wrapper sets touch-action: pan-y for horizontal flipbooks (so the page can still scroll vertically) and pan-x for vertical. The biggest perf win on low-end devices is shipping appropriately sized page images: pre-resize to roughly the rendered pixel size before bundling, since image.decode() dominates the initial mount cost.