Turn the page.

A realistic page curl for any HTML. Drag a corner, tap an edge, or turn it from code. For React or plain JavaScript.

Drag the corner, click an edge, or press ← →

20 kBmin + gzip
0dependencies, React is a peer
WebGLdrawn on the GPU

Installation

Terminal
$ npm install @pagecurl/react

Not using React?

Terminal
$ npm install @pagecurl/core

Usage

Every child is a page. Give the book a size, and that's it.

import { PageCurl } from '@pagecurl/react';

export function Book() {
  return (
    <PageCurl style={{ width: 360, height: 500 }}>
      <Cover />
      <Chapter />
      <Notes />
    </PageCurl>
  );
}

Turn pages from anywhere: buttons, keys, timers, your own gestures.

Reader.tsx
const book = useRef<PageCurlHandle>(null);

<PageCurl ref={book} onPageChange={setPage}>…</PageCurl>

book.current?.next();
book.current?.prev();
book.current?.goTo(4);

Guides

Sizing

The book is the element you pass in, or the div that <PageCurl> renders. Give it a width and a height. Every page is stretched to fill it, or half of it each in spread mode, so design pages to that size.

styles.css
.book {
  width: 400px;
  aspect-ratio: 5 / 7; /* or a height */
}

The book follows its own size. When it changes, pages are laid out again and recaptured, so a fluid width or a media query just works.

Spreads and right-to-left

spread shows two pages side by side, like an open book. The page number is then always the left page of the spread, and a turn moves two pages. direction="rtl" binds the book on the right for Arabic, Hebrew or manga.

Book.tsx
<PageCurl spread direction="rtl" style={{ width: 800, height: 560 }}>
  {pages}
</PageCurl>

Spreads start with pages 0 and 1 side by side. To open on a single page on the right, as a printed book does, put an empty page first.

Controlling the page

In React, pass page and onPageChange to keep the page in your own state. A change of one turn animates. Anything further jumps straight there.

Reader.tsx
const [page, setPage] = useState(0);

<PageCurl page={page} onPageChange={setPage}>…</PageCurl>

<input
  type="range"
  max={pageCount - 1}
  value={page}
  onChange={(e) => setPage(Number(e.target.value))}
/>

Prefer defaultPage when you only need a starting point and don't mind the component keeping its own state.

Turning from code

next(), prev() and turn(direction) animate a turn and return a promise. It resolves to true when the page lands, or to false straight away if there is no page to turn to. goTo(page) jumps without animating.

book.js
if (curl.canTurn('next')) {
  const turned = await curl.next(); // resolves when the page lands
}

curl.goTo(12); // no animation

Keyboard shortcuts are left to you, so they don't clash with the rest of your app.

Reader.tsx
useEffect(() => {
  const onKey = (e: KeyboardEvent) => {
    if (e.key === 'ArrowRight') book.current?.next();
    if (e.key === 'ArrowLeft') book.current?.prev();
  };
  window.addEventListener('keydown', onKey);
  return () => window.removeEventListener('keydown', onKey);
}, []);

Driving a drag yourself

A drag doesn't have to come from the pointer. dragStart, dragMove and dragEnd move the curl as if a finger held the page, so you can tie it to a slider, the scroll position or a gesture library.

slider.js
// Coordinates are CSS pixels inside the book.
const { width, height } = book.getBoundingClientRect();

slider.onpointerdown = () => curl.dragStart(width, height, 'next');
slider.oninput = () => curl.dragMove(width * (1 - slider.value), height);
slider.onpointerup = () => curl.dragEnd(); // completes past the middle

Without a direction, a drag that starts on the right half turns forward. Pass { complete: true } to dragEnd to finish the turn wherever the drag stopped, or false to let the page fall back.

Links, buttons and text

While pages lie flat they are your live DOM. Links, buttons, inputs, focus and screen readers all work, and only the current pages can be reached.

  • Clicking a link or a button never counts as a tap, and form fields never start a drag.
  • With a mouse, a drag that starts on text selects it. A drag from blank paper, or from within grabZone of the book's edges, turns the page. Add user-select: none to pages that should turn from anywhere.
  • On touch it works like a reading app: a drag anywhere on the book, in any direction, turns the page, and the page never scrolls while a finger is on the book. A long press selects text as usual.
  • Taps only turn the page near the edges. tapZone sets how wide those strips are.

Anything that needs the pointer for itself, like a map, a canvas you draw on or a carousel, should opt out with data-pagecurl-ignore:

Page.tsx
<figure data-pagecurl-ignore>
  <Map /> {/* pans instead of turning the page */}
</figure>

The ignore option replaces the selector entirely, so include the form fields if you still want them left alone.

Pages that are already pixels

When a turn starts, the turning page is captured as an image. If a page is already drawn, such as a PDF or a chart on a <canvas>, hand that over instead of capturing the DOM:

viewer.js
createPageCurl(book, {
  snapshot: (page) => page.querySelector('canvas'),
});

The snapshot may also be a promise. The turn waits for it, which lets you render a page only when someone is about to see it:

viewer.js
createPageCurl(book, {
  // Render the page the first time it's about to turn.
  snapshot: async (page, { width, height, pixelRatio }) => {
    const canvas = page.querySelector('canvas');
    await renderPdfPage(canvas, width * pixelRatio, height * pixelRatio);
    return canvas;
  },
});

Snapshots are cached until the page's markup changes. Redrawing a canvas isn't a markup change, so call refresh() after it.

What can’t be captured

The built-in capture clones the page with its styles and draws it through SVG, with images and web fonts embedded. A few things don't survive that:

  • Images and fonts from other origins without CORS headers.
  • The contents of <iframe>s, video frames and WebGL canvases.
  • Scrolled content inside a page, which is captured scrolled to the top.
  • Animations, which are captured at the frame they were on.

Use a custom snapshot for pages like these. Without WebGL, pages still turn, just without the curl.

API reference

createPageCurl

From @pagecurl/core. Turns every child of element into a page and returns a controller.

index.d.ts
function createPageCurl(element: HTMLElement, options?: PageCurlOptions): PageCurl

<PageCurl>

From @pagecurl/react. Each child is one page. It takes every option as a prop, plus these. Any other div props, like className, style and aria-*, are passed through. The ref gives you the methods.

Book.tsx
<PageCurl ref={book} className="book" spread onPageChange={setPage}>
  {children}
</PageCurl>
pagenumber
Controlled page. A change of one turn animates; larger jumps are immediate.
defaultPagenumber
Starting page when page isn't controlled.Default: 0

usePageCurl

From @pagecurl/react, for when you'd rather render the book element yourself. Takes the same options.

Book.tsx
const { ref, curl, page } = usePageCurl({ spread: true });

return <div ref={ref} className="book">{pages}</div>;
refRefCallback
Attach it to the book element.
curlPageCurl | null
The controller, once the element has mounted.
pagenumber
The current page, kept in state so the component re-renders when it changes.

Options

Passed to createPageCurl or as props. Change them later with setOptions, or by passing new props.

pagenumber
Starting page. In spread mode, rounded down to the left page of its spread.Default: 0
spreadboolean
Show two pages side by side.Default: false
direction'ltr' | 'rtl'
'rtl' binds the book on the right.Default: 'ltr'
pageColorstring
Paper colour, used to tint the back of a turning page and its shadows. Any CSS colour.Default: page background
turnDurationnumber
Seconds for a turn started by a tap or from code.Default: 0.25
dragDurationnumber
Seconds for a drag to catch up with the pointer, and to settle after release.Default: 0.2
dragboolean
Turn pages by dragging.Default: true
tapboolean
Turn pages by tapping near the left or right edge.Default: true
tapZonenumber
Width of the tap strips, in CSS pixels.Default: 110, or 82 in spreads
grabZonenumber
Width of the strips along the book's left and right edges where a mouse drag always turns the page, even when it starts on text. Elsewhere a drag on text selects it.Default: 32
ignorestring
Elements matching this selector never start a drag or a tap. The default covers inputs, text areas, selects, editable content and [data-pagecurl-ignore].Default: form fields
overflownumber
CSS pixels around the book the curl may draw into, as a page lifts past its edges.Default: 25% of the longer side
pixelRationumber
Rendering resolution. Lower it to save memory on very large books.Default: devicePixelRatio
snapshotSnapshotter
Replaces how a page is captured. See Snapshotter.Default: built in
liveFlatboolean
During a turn, keep the part of the page that hasn't started to bend as the live page, and draw only the bending part from its snapshot. Text that isn't moving can't shift. See the FAQ.Default: false
htmlInCanvasboolean
Experimental. Where the browser supports HTML-in-Canvas, the browser paints pages for the curl itself, so even the bending part matches the page exactly. Ignored with a custom snapshot. See the FAQ.Default: false
onPageChange(page: number) => void
Called whenever the current page changes.
onTurnStart(event: TurnEvent) => void
Called when a turn begins. See Events.
onTurnEnd(event: TurnEndEvent) => void
Called when a turn has settled, whether it completed or fell back.

Methods and properties

On the object createPageCurl returns, and on a ref to <PageCurl>. Directions are 'next' or 'prev', and coordinates are CSS pixels from the book's top left corner.

next()Promise<boolean>
Animate a turn forward. Resolves to true when it lands, or false if there's no next page.
prev()Promise<boolean>
Animate a turn back.
turn(direction)Promise<boolean>
next() or prev(), chosen by a value.
goTo(page)void
Show a page immediately, cancelling any turn in progress.
canTurn(direction)boolean
Whether there is a page that way.
dragStart(x, y, direction?)boolean
Start a drag as if a pointer pressed there. Returns false if there is nowhere to turn or a drag is already active.
dragMove(x, y)void
Move the drag.
dragEnd({ complete? })void
Release the drag. It completes when released past the middle of the book, unless complete says otherwise.
refresh()void
Drop cached snapshots and re-read the pages. Adding, removing or editing pages is picked up on its own, so this is only for changes the book can't see.
setOptions(options)void
Change options. Core only; in React, pass new props.
destroy()void
Remove listeners and restore the element. Core only; React does it on unmount.
pagenumber
Current page, the left page of the spread in spread mode.
pageCountnumber
Number of pages.
turningboolean
A turn is animating or being dragged.
elementHTMLElement
The book element.

Events

onTurnStart receives a TurnEvent, and onTurnEnd a TurnEndEvent, which adds completed.

fromnumber
Page shown before the turn.
tonumber
Page shown if the turn completes.
direction'next' | 'prev'
Which way the page is turning.
interactiveboolean
Started by a drag, rather than a tap or a call.
completedboolean
onTurnEnd only. False when a drag was let go and the page fell back.

Snapshotter

The type of the snapshot option. It gets the page element and the size to draw at, and returns anything WebGL can upload: a canvas, an image, an ImageBitmap or a video frame, or a promise of one.

index.d.ts
type Snapshotter = (
  page: HTMLElement,
  options: { width: number; height: number; pixelRatio: number; background?: string },
) => TexImageSource | Promise<TexImageSource>;

The built-in snapshot function is exported from @pagecurl/core, so a custom one can fall back to it for some pages.

FAQ

Why does text shift a little when a page starts to turn?

Browsers can't take a picture of an element. So when a turn starts, the page is copied and drawn a second time as an image, through SVG, and that image is what curls. The copy is laid out again from scratch, with its fonts embedded separately and at a different scale, and the text can land a fraction of a pixel away from where it was. When the image takes over from the live page, that difference shows as a small jump.

How much depends on the browser, the fonts, emoji and the zoom level. Often it's nothing at all. Two options remove it.

How do I make turns pixel-exact?

Turn on one option or both:

<PageCurl liveFlat htmlInCanvas>
  {pages}
</PageCurl>
  • liveFlat works in every browser. While a page turns, the part that hasn't started to bend stays the live page, cut along the fold, and only the bending part comes from the image. Text that isn't moving never switches to the image, so it can't jump. Any difference is left inside the part that is curling, where the paper is already bending and shading.
  • htmlInCanvas has the browser paint the page into the curl itself, so the image matches the page exactly, bending part included. It needs a browser that supports it, and does nothing elsewhere.

Two things make the default exact on their own. Size text, line heights and padding in whole pixels, since fractions are what round differently in the snapshot. And pages that are already pixels, like images, canvases or PDF pages, never shift when you pass them as their own snapshot: the image that curls is the page you see. The demo on this page is built that way.

Which browsers support htmlInCanvas?

It uses HTML-in-Canvas, a new web API that is still being designed. Today it's only in Chrome, behind the chrome://flags/#enable-experimental-web-platform-features flag, or for every Chrome visitor of a site that has joined Chrome's origin trial:

index.html
<!-- The token from your origin trial registration -->
<meta http-equiv="origin-trial" content="YOUR_TOKEN" />

Everywhere else, and if the API changes before it ships, pages are captured the usual way. Pair it with liveFlat so those browsers are covered too.

Why aren't they on by default?

Both are new. liveFlat changes how a turning page is shown, and htmlInCanvas builds on an API that is still changing, so they're opt-in while they settle.

Is there anything they don't cover?

  • liveFlat applies while one page is turning. If several turns overlap, as in fast flipping, the turning pages are drawn from images until they settle.
  • In spread mode, the page arriving on the back of the turning page is drawn from its image until it lands.
  • htmlInCanvas curls a still copy of the page, kept hidden inside the book's canvas. Videos and iframes come out blank in it, and code that searches the whole document, like querySelectorAll, finds the copies too.
  • With a custom snapshot, such as a PDF page's own canvas, htmlInCanvas is ignored. liveFlat still works.