Mantine Book

@gfazioli/mantine-book

A realistic iBooks-style book component for React, built on Mantine. Stack two-sided pages and turn them by dragging any point of the free edge in any direction — a pure-DOM reflection fold (flat) or a true 3D WebGL curl (rounded), with controlled page navigation.

Installation

yarn add @gfazioli/mantine-book

After installation import package styles at the root of your application:

import '@gfazioli/mantine-book/styles.css';

You can import styles within a layer @layer mantine-book by importing @gfazioli/mantine-book/styles.layer.css file.

import '@gfazioli/mantine-book/styles.layer.css';

Usage

Book renders pages you can turn with a realistic iBooks-style page curl. Each Book.Page is the physical entity that turns: it always has two sides, declared as Book.Page.Front and Book.Page.Back. Grab any point of the page's free edge and drag in any direction to fold it. The play-zone is twice the page width: the page rests in the right half and sweeps toward the spine (the centre) as it curls; once turned, it rests in the left half and folds back the other way. The simplest book is a single page:

Page 1 of 2
Front A
import { Book } from '@gfazioli/mantine-book';
import { Face } from './Face';

function Demo() {
  return (
    <Book width={300} height={420}>
      <Book.Page>
        <Book.Page.Front>
          <Face label="Front A" color="#4263eb" />
        </Book.Page.Front>
        <Book.Page.Back>
          <Face label="Back B" color="#e8590c" />
        </Book.Page.Back>
      </Book.Page>
    </Book>
  );
}

Faces

A page has two faces, declared as compound children: Book.Page.Front (the resting side) and Book.Page.Back (revealed as the page turns). Each face can hold any React node — text, images, MDX, even a <canvas> — rendered once and owned by React, so event handlers inside a face stay alive.

Book.Page.Back is optional: omit it and the back renders blank (filled with the page background). Toggle it below:

Page 1 of 2
Front A
Variant
import { Book } from '@gfazioli/mantine-book';
import { Face } from './Face';

function Demo() {
  return (
    <Book width={260} height={360}>
      <Book.Page>
        <Book.Page.Front>
          <Face label="Front A" color="#4263eb" />
        </Book.Page.Front>
        <Book.Page.Back>
          <Face label="Back B" color="#e8590c" />
        </Book.Page.Back>
      </Book.Page>
    </Book>
  );
}

Content and alignment

Each face clips overflow and centers smaller content by default. Use the align prop on the Book for every face, or per-face on Book.Page.Front / Book.Page.Back to override. It accepts { horizontal, vertical }, each one of start / center / end, and only affects content smaller than the face:

Page 1 of 2
Front
Variant
Horizontal
Vertical
import { Book } from '@gfazioli/mantine-book';

/** Content smaller than the face, so its placement is visible. */
function Chip({ label }: { label: string }) {
  return (
    <div
      style={{
        padding: '10px 18px',
        borderRadius: 8,
        background: '#4263eb',
        color: '#fff',
        fontWeight: 700,
      }}
    >
      {label}
    </div>
  );
}

function Demo() {
  return (
    <Book width={260} height={360} pageBackground="#f1f3f5" align={{ horizontal: 'center', vertical: 'center' }}>
      <Book.Page>
        <Book.Page.Front>
          <Chip label="Front" />
        </Book.Page.Front>
        <Book.Page.Back>
          <Chip label="Back" />
        </Book.Page.Back>
      </Book.Page>
    </Book>
  );
}

Media content

img, svg, video and canvas are automatically scaled to fit the face (object-fit: contain), so oversized artwork is letterboxed rather than cropped — or size them to the face with object-fit: cover for a full-page image:

Page 1 of 2
Front cover artwork
Fit
Variant
import { Book } from '@gfazioli/mantine-book';

function Demo() {
  // For a full-page image, size it to the face and switch to cover:
  const imgStyle = { width: '100%', height: '100%', objectFit: 'cover' } as const;

  return (
    <Book width={260} height={360}>
      <Book.Page>
        <Book.Page.Front>
          {/* crossOrigin keeps the snapshot origin-clean for the rounded variant */}
          <img
            src="https://raw.githubusercontent.com/mantinedev/mantine/master/.demo/images/bg-7.png"
            alt="Front cover artwork"
            crossOrigin="anonymous"
            style={imgStyle}
          />
        </Book.Page.Front>
        <Book.Page.Back>
          <img
            src="https://raw.githubusercontent.com/mantinedev/mantine/master/.demo/images/bg-8.png"
            alt="Back cover artwork"
            crossOrigin="anonymous"
            style={imgStyle}
          />
        </Book.Page.Back>
      </Book.Page>
    </Book>
  );
}

Variants and curl shape

The page turn ships two rendering paths, selected with the variant prop (set it on the Book for every page, or per page to mix them):

variantRendererNotes
flat (default)Pure-DOM reflection fold (CSS clip-path + gradient)Fully interactive at rest, SSR-safe, the universal fallback
roundedTrue 3D curl drawn on a WebGL canvas

Faces are a static snapshot during the curl; the wrap tightness is set by curlRadius

The default flat variant needs no canvas and works everywhere. If WebGL is unavailable or a face snapshot fails, rounded falls back to flat automatically, so it is always safe to opt in. Switch the variant and drag the page to compare them:

Page 1 of 2
Front A
Variant
import { Book } from '@gfazioli/mantine-book';
import { Face } from './Face';

function Demo() {
  return (
    <Book variant="rounded" width={260} height={360}>
      <Book.Page>
        <Book.Page.Front>
          <Face label="Front A" color="#4263eb" />
        </Book.Page.Front>
        <Book.Page.Back>
          <Face label="Back B" color="#e8590c" />
        </Book.Page.Back>
      </Book.Page>
    </Book>
  );
}

Curl radius

curlRadius (rounded only, in px, default ~0.32 × width) sets how tightly the page wraps: a smaller radius makes a tight roll that turns the page sooner, a larger one a gentle, loose curl. Drag the slider, then drag the page:

Page 1 of 2
Front A
Curl radius
import { Book } from '@gfazioli/mantine-book';
import { Face } from './Face';

function Demo() {
  return (
    <Book variant="rounded" curlRadius={90} width={260} height={360}>
      <Book.Page>
        <Book.Page.Front>
          <Face label="Front A" color="#4263eb" />
        </Book.Page.Front>
        <Book.Page.Back>
          <Face label="Back B" color="#e8590c" />
        </Book.Page.Back>
      </Book.Page>
    </Book>
  );
}

Dimensions

width and height size the pages in CSS px, set once on the Book and applied to every page. The interactive play-zone is twice the width: the pages rest in the right half and the curl sweeps left toward the spine, so a width={260} book occupies a 520px-wide box.

Page 1 of 2
Front A
Variant
Width
Height
import { Book } from '@gfazioli/mantine-book';
import { Face } from './Face';

function Demo() {
  // The play-zone is 2 x width: the page rests in the right half and the
  // curl sweeps left toward the spine (the centre).
  return (
    <Book width={240} height={340}>
      <Book.Page>
        <Book.Page.Front>
          <Face label="Front A" color="#4263eb" />
        </Book.Page.Front>
        <Book.Page.Back>
          <Face label="Back B" color="#e8590c" />
        </Book.Page.Back>
      </Book.Page>
    </Book>
  );
}

Multi-page books

Stack several pages and the Book behaves like a real book. Drag the right half to turn the current page forward and the left half to turn the previous one back. While a page curls, the page beneath shows through — the next one going forward, the previous one going back. Visual and gesture props set on the Book (variant, shadows, timings…) are inherited by every page via optional context, and any page can override them locally.

The page state follows the usual Mantine controlled/uncontrolled pattern: page + onPageChange for controlled, defaultPage for uncontrolled. The index counts faces: page i has its front at 2i and its back at 2i + 1, so 0 shows the closed book and 1 shows the first page turned. Both faces of a spread are equivalent as a setter; onPageChange reports the first visible face in reading order.

Page 1 of 8
Page 1
Page 3
Page 5
Page 7
page 0 / 7
Variant
import { useState } from 'react';
import { Book } from '@gfazioli/mantine-book';
import { Badge, Stack } from '@mantine/core';
import { Face } from './Face';

// One color pair per page so the stack order reads at a glance.
const PAGES = [
  { front: '#4263eb', back: '#3b5bdb' },
  { front: '#e8590c', back: '#d9480f' },
  { front: '#2f9e44', back: '#2b8a3e' },
  { front: '#9c36b5', back: '#862e9c' },
];

function Demo() {
  const [page, setPage] = useState(0);
  const lastFace = PAGES.length * 2 - 1;

  return (
    <Stack align="center" gap="md">
      <Book width={260} height={360} onPageChange={setPage}>
        {PAGES.map((colors, index) => (
          <Book.Page key={index}>
            <Book.Page.Front>
              <Face label={`Page ${index * 2 + 1}`} color={colors.front} />
            </Book.Page.Front>
            <Book.Page.Back>
              <Face label={`Page ${index * 2 + 2}`} color={colors.back} />
            </Book.Page.Back>
          </Book.Page>
        ))}
      </Book>
      <Badge variant="light" size="lg">
        page {page} / {lastFace}
      </Badge>
    </Stack>
  );
}

Data-driven pages

Instead of children, pass a pages array of { front, back, props? } objects — handy when the book is built from data (a gallery, a CMS, a PDF manifest). The optional props entry carries per-page overrides and wins over the Book's inherited props:

Page 1 of 6
Cover
Chapter 1
Gallery
import { Book, type BookPageData } from '@gfazioli/mantine-book';
import { Face } from './Face';

const pages: BookPageData[] = [
  {
    front: <Face label="Cover" color="#4263eb" />,
    back: <Face label="Intro" color="#3b5bdb" />,
  },
  {
    front: <Face label="Chapter 1" color="#e8590c" />,
    back: <Face label="Chapter 2" color="#d9480f" />,
  },
  {
    front: <Face label="Gallery" color="#2f9e44" />,
    back: <Face label="Credits" color="#2b8a3e" />,
    // the optional props entry carries per-page overrides and wins over
    // the Book's inherited props, e.g. props: { variant: 'flat' }
  },
];

function Demo() {
  return <Book width={260} height={360} pages={pages} />;
}

Standalone page

A Book.Page also works on its own, outside any Book — it sizes itself with its own width / height and turns like a single sheet (standalone, the whole play-zone is the grab surface; grabZone="sheet" restricts the grab to the resting page itself). The resting side is also controllable: pass flipped together with onFlip and an external change runs the same animated turn as a drag. Inside a Book, the book's width / height apply to every page instead:

Front A
import { Book } from '@gfazioli/mantine-book';
import { Face } from './Face';

function Demo() {
  // Outside a <Book> a page stands alone and sizes itself.
  return (
    <Book.Page width={300} height={420}>
      <Book.Page.Front>
        <Face label="Front A" color="#4263eb" />
      </Book.Page.Front>
      <Book.Page.Back>
        <Face label="Back B" color="#e8590c" />
      </Book.Page.Back>
    </Book.Page>
  );
}

External navigation

Because page is controlled, the book can be driven entirely from outside — arrows, a pagination, keyboard shortcuts. A programmatic page change runs the same settle animation as a completed drag: turnOrigin picks the simulated grab point — bottom or top curl from that corner with a diagonal crease, middle folds the page straight over — and flippingTime sets the speed (tune both below). Dragging the pages keeps working alongside the controls.

Turns are queued, one page in flight at a time: rapid clicks or key presses never overlap two animations — they accumulate and the book riffles through them in order, compressing the per-page duration on long jumps so the whole run fits the riffleDuration budget (one second by default — slow it down below to watch every page go by). The in-between pages of a riffle turn with the flat fold whatever the variant — at riffle speed the curl is indistinguishable, and the flat path keeps long runs smooth — while the final landing page turns with the book's variant. Try hammering the next arrow.

Page 1 of 8
Page 1
Page 3
Page 5
Page 7
page 0 / 7
Variant
Flipping time
Riffle duration
Turn origin
import { useState } from 'react';
import { Book, type BookPageData } from '@gfazioli/mantine-book';
import { Stack } from '@mantine/core';
import { Face } from './Face';
import { Pager } from './Pager';

const COLORS = [
  { front: '#4263eb', back: '#3b5bdb' },
  { front: '#e8590c', back: '#d9480f' },
  { front: '#2f9e44', back: '#2b8a3e' },
  { front: '#9c36b5', back: '#862e9c' },
];

const pages: BookPageData[] = COLORS.map((colors, index) => ({
  front: <Face label={`Page ${index * 2 + 1}`} color={colors.front} />,
  back: <Face label={`Page ${index * 2 + 2}`} color={colors.back} />,
}));

function Demo() {
  const [page, setPage] = useState(0);

  return (
    <Stack align="center" gap="md">
      <Book width={260} height={360} page={page} onPageChange={setPage} pages={pages} />
      <Pager page={page} pageCount={pages.length} onChange={setPage} />
    </Stack>
  );
}

Covers

withCover treats the book as a physical bound volume. The first and last pages turn rigid — the whole sheet rotates flat around the spine under the play-zone perspective, like a hard cover, while the inner pages keep their soft curl (a page can opt in or out with its own hard prop). Rigid pages are pure DOM — no WebGL even in a rounded book, so covers are always crisp and cost nothing on the GPU. And the closed book is compact: at either end the single visible page is centered on the play-zone, sliding into the two-page spread as the cover opens — the slide is a CSS transition, disabled under prefers-reduced-motion. Drag the cover slowly to feel the difference: it stays flat all the way over.

Page 1 of 8
The Book
a Mantine extension
1
3
ii
Variant
import { useState } from 'react';
import { Book, type BookPageData } from '@gfazioli/mantine-book';
import { Stack } from '@mantine/core';
import { Cover, Paper } from './BookFaces';
import { Pager } from './Pager';

const pages: BookPageData[] = [
  { front: <Cover title="The Book" subtitle="a Mantine extension" />, back: <Paper label="i" tone={1} /> },
  { front: <Paper label="1" />, back: <Paper label="2" tone={1} /> },
  { front: <Paper label="3" />, back: <Paper label="4" tone={1} /> },
  { front: <Paper label="ii" tone={1} />, back: <Cover title="The End" subtitle="back cover" /> },
];

function Demo() {
  const [page, setPage] = useState(0);

  return (
    <Stack align="center" gap="md">
      {/* withCover: the first and last pages turn RIGID around the spine
          (hard covers — no curl), and the CLOSED book is compact: centered
          on the play-zone, sliding into the spread as the cover opens. */}
      <Book withCover width={240} height={340} page={page} onPageChange={setPage} pages={pages} />
      <Pager page={page} pageCount={pages.length} onChange={setPage} />
    </Stack>
  );
}

Large books

The page stack is built to scale: a page costs its resting DOM and nothing more. With variant="rounded" the whole book shares one WebGL context (created lazily on the first turn), and only the touchable spread keeps its face snapshots warm — a buried page captures on demand the moment its turn starts, and the in-between steps of a riffle skip the WebGL pipeline entirely. The per-page warmSnapshots prop exposes that same switch for a standalone page. So the 100-page book below costs the same GPU resources as a single sheet — each page's hue advances by the golden angle, so neighbouring pages contrast strongly while the whole book still spans the color wheel. Drag the slider for a long riffle:

Page 1 of 200
1
3
5
7
9
11
13
15
17
19
21
23
25
27
29
31
33
35
37
39
41
43
45
47
49
51
53
55
57
59
61
63
65
67
69
71
73
75
77
79
81
83
85
87
89
91
93
95
97
99
101
103
105
107
109
111
113
115
117
119
121
123
125
127
129
131
133
135
137
139
141
143
145
147
149
151
153
155
157
159
161
163
165
167
169
171
173
175
177
179
181
183
185
187
189
191
193
195
197
199
page 0 / 199
Variant
Riffle duration
import { useState } from 'react';
import { Book, type BookPageData } from '@gfazioli/mantine-book';
import { Stack } from '@mantine/core';
import { Face } from './Face';
import { Pager } from './Pager';

const PAGE_COUNT = 100;
const GOLDEN_ANGLE = 137.508;

// Hue advances by the golden angle per page: adjacent pages contrast
// strongly while the whole book still covers the color wheel evenly.
const pages: BookPageData[] = Array.from({ length: PAGE_COUNT }, (_, index) => {
  const hue = Math.round((index * GOLDEN_ANGLE) % 360);
  return {
    front: <Face label={`${index * 2 + 1}`} color={`hsl(${hue} 62% 46%)`} />,
    back: <Face label={`${index * 2 + 2}`} color={`hsl(${hue} 62% 32%)`} />,
  };
});

function Demo() {
  const [page, setPage] = useState(0);

  return (
    <Stack align="center" gap="md">
      <Book variant="rounded" riffleDuration={1500} width={220} height={300} page={page} onPageChange={setPage} pages={pages} />
      <Pager page={page} pageCount={PAGE_COUNT} onChange={setPage} sliderWidth={440} />
    </Stack>
  );
}

Shadows and lighting

shadowOpacity (0–1) controls how strongly the lifting page is shaded as it curves away from the light, and shadowColor (a MantineColor or any CSS color) tints that shadow. Both apply to either variant.

On top of the shadow, the rounded variant also lights its own 3D surface: a Lambert term darkens the curve, a tight specular ridge highlights the apex, and the light automatically follows the direction you fold so the gloss reads in both turn directions. This lighting needs no configuration — it is part of the 3D render, which is why there is no separate lighting control.

Page 1 of 2
Front A
Variant
Shadow opacity
Shadow color
import { Book } from '@gfazioli/mantine-book';
import { Face } from './Face';

function Demo() {
  return (
    <Book shadowOpacity={0.5} shadowColor="#1a1b1e" width={260} height={360}>
      <Book.Page>
        <Book.Page.Front>
          <Face label="Front A" color="#4263eb" />
        </Book.Page.Front>
        <Book.Page.Back>
          <Face label="Back B" color="#e8590c" />
        </Book.Page.Back>
      </Book.Page>
    </Book>
  );
}

Page background and reveal

pageBackground (a MantineColor or any CSS color) fills each face behind its content, so transparent content sits on a solid page.

revealBackground paints what the curl uncovers. On the Book it is the inside cover: a base under the whole stack, visible where no page rests (the left half before the first turn, the right half past the last) and through the curl of the first and last page — between two inner pages the natural reveal is the next page itself. On a single Book.Page it paints a layer under that page only, for standalone peel effects.

Page 1 of 2
Front
Variant
Page background
Reveal background
import { Book } from '@gfazioli/mantine-book';

/** Transparent content so the page background shows through. */
function Label({ text }: { text: string }) {
  return <div style={{ fontSize: 32, fontWeight: 700, color: '#1a1b1e' }}>{text}</div>;
}

function Demo() {
  return (
    // revealBackground = the "inside cover" painted under the whole stack:
    // visible where no page rests and through the curl of the first/last page
    <Book pageBackground="#fff9db" revealBackground="#343a40" width={260} height={360}>
      <Book.Page>
        <Book.Page.Front>
          <Label text="Front" />
        </Book.Page.Front>
        <Book.Page.Back>
          <Label text="Back" />
        </Book.Page.Back>
      </Book.Page>
    </Book>
  );
}

Interaction and release

When you release a drag the page settles with an animation over flippingTime ms. flipThreshold (0–100, percent of a full turn) is the cutoff: drag past it and the page completes the turn; below it, it settles back to rest. disabled removes the drag interaction entirely — on the Book for every page, or on a single Book.Page to pin just that one.

Page 1 of 2
Front A
Variant
Flipping time
Flip threshold
import { Book } from '@gfazioli/mantine-book';
import { Face } from './Face';

function Demo() {
  // On release: drag past flipThreshold (% of a full turn) and the page
  // settles open over flippingTime ms; below it, it settles back to rest.
  return (
    <Book width={260} height={360}>
      <Book.Page>
        <Book.Page.Front>
          <Face label="Front A" color="#4263eb" />
        </Book.Page.Front>
        <Book.Page.Back>
          <Face label="Back B" color="#e8590c" />
        </Book.Page.Back>
      </Book.Page>
    </Book>
  );
}

Touch gestures

On touch devices the page turn also recognizes a swipe — a quick flick that completes the turn even when you release below flipThreshold. A gesture counts as a swipe only when it travels at least swipeDistance px in under swipeTimeThreshold ms, so raising swipeDistance (or lowering swipeTimeThreshold) demands a longer / faster flick.

mobileScrollSupport (default true) keeps vertical page scrolling alive: the component waits for a clearly horizontal gesture before claiming the touch, so a vertical drag that starts on the book still scrolls the page. Set it to false to capture every touch on the book immediately (page scroll from the book is then blocked).

Page 1 of 2
Front A

These tune swipe recognition on touch devices — try them on a phone or a trackpad.

Swipe distance
Swipe time threshold
import { Book } from '@gfazioli/mantine-book';
import { Face } from './Face';

function Demo() {
  // Touch-gesture tuning: a quick flick counts as a swipe when it covers at
  // least swipeDistance within swipeTimeThreshold ms. mobileScrollSupport
  // waits for a horizontal-biased gesture before claiming the touch, so
  // vertical page scroll keeps working.
  return (
    <Book width={260} height={360}>
      <Book.Page>
        <Book.Page.Front>
          <Face label="Front A" color="#4263eb" />
        </Book.Page.Front>
        <Book.Page.Back>
          <Face label="Back B" color="#e8590c" />
        </Book.Page.Back>
      </Book.Page>
    </Book>
  );
}

Keyboard shortcuts

The book is keyboard-operable out of the box: focus it (it is in the tab order) and turn the pages from the keyboard — every keyboard turn plays the same animated curl as the external controls, and collapses to an instant settle under prefers-reduced-motion: reduce. Keys are handled only while the book itself has focus, so interactive content inside a face keeps its own keyboard behavior.

KeyAction
Turn the current page forward
Turn the previous page back
HomeJump to the front cover (closed book)
EndJump past the last page (back cover)

For assistive technology the root exposes role="group" with aria-roledescription="book" (give it your own name with aria-label), and a polite live region announces the visible pages after every turn — "Page 1 of 6" when a single page is visible, "Pages 2–3 of 6" on an open spread. Use pageAnnouncement to localize or customize the message:

<Book pageAnnouncement={({ from, to, total }) => `Pagine ${from}–${to} di ${total}`}>
  …
</Book>

Events

onFold fires throughout an interaction with { progress, phase }: phase is 'grab' the instant the pointer takes the page (before any movement), 'move' while dragging, and 'settle' during the release animation. onFlip fires once when a settle finishes, with the resting { flipped } state — both are per page, on Book.Page. The Book-level onPageChange fires when a completed turn changes the page. Drag the page and watch the readout:

Page 1 of 2
Front A

onFold: { progress: 0, phase: '—' }

onFlip: { flipped: false }

Variant
import { useState } from 'react';
import { Book } from '@gfazioli/mantine-book';
import { Code, Group, Text } from '@mantine/core';
import { Face } from './Face';

function Demo() {
  const [progress, setProgress] = useState(0);
  const [phase, setPhase] = useState<'grab' | 'move' | 'settle' | '—'>('—');
  const [flipped, setFlipped] = useState(false);

  return (
    <>
      <Book width={260} height={360}>
        <Book.Page
          onFold={(info) => {
            setProgress(info.progress);
            setPhase(info.phase);
          }}
          onFlip={(info) => setFlipped(info.flipped)}
        >
          <Book.Page.Front>
            <Face label="Front A" color="#4263eb" />
          </Book.Page.Front>
          <Book.Page.Back>
            <Face label="Back B" color="#e8590c" />
          </Book.Page.Back>
        </Book.Page>
      </Book>

      <Group gap="lg" justify="center" mt="md">
        <Text size="sm">
          onFold: <Code>{`{ progress: ${progress.toFixed(0)}, phase: '${phase}' }`}</Code>
        </Text>
        <Text size="sm">
          onFlip: <Code>{`{ flipped: ${flipped} }`}</Code>
        </Text>
      </Group>
    </>
  );
}

Styles API

Book supports the full Mantine Styles API. Use classNames, styles, vars and unstyled to customize the book (root, the per-page page wrapper) and every layer of a page — the resting sheet, the lifting flap, the rigid cover sheet, the shadow and reveal layers, the face content wrapper. Use the interactive playground below to inspect every selector and CSS variable; the full reference for both levels lives in the Styles API tab above.

Front

Component Styles API

Hover over selectors to highlight corresponding elements

/*
 * Hover over selectors to apply outline styles
 *
 */