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 ← →
Installation
$ npm install @pagecurl/react
Not using React?
$ 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.
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.
.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.
<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.
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.
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.
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.
// 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
grabZoneof the book's edges, turns the page. Adduser-select: noneto 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.
tapZonesets 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:
<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:
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:
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.
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.
<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
pageisn't controlled.Default:0
usePageCurl
From @pagecurl/react, for when you'd rather render the book element yourself. Takes the same options.
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
truewhen it lands, orfalseif there's no next page. prev()Promise<boolean>- Animate a turn back.
turn(direction)Promise<boolean>next()orprev(), 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
falseif 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
completesays 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.
completedbooleanonTurnEndonly. 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.
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>
liveFlatworks 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.htmlInCanvashas 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:
<!-- 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?
liveFlatapplies 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.
htmlInCanvascurls 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, likequerySelectorAll, finds the copies too.- With a custom snapshot, such as a PDF page's own canvas,
htmlInCanvasis ignored.liveFlatstill works.