turn-ts-react
v0.1.1
Published
React 19 bindings for turn-ts. A page-flip book component with an explicit "use client" boundary.
Maintainers
Readme
turn-ts-react
React 19 bindings for turn-ts, the framework-agnostic
page-flip engine. Two components, an imperative escape hatch on ref, and an explicit
"use client" boundary so it drops straight into a Next.js App Router app.
[!IMPORTANT] Not open source. Non-commercial use only. The runtime dependency
turn-tsis a derivative of turn.js (3rd release), whose licence permits use "solely for personal benefit and not for any commercial purpose or for monetary gain." That restriction reaches this package through the dependency and cannot be removed here. Read LICENSE.md before installing.
Install
npm i turn-ts-reactreact and react-dom ^19 are peer dependencies. turn-ts is a direct dependency — you do not
install it yourself, but you do import its stylesheet.
Use
'use client';
import { TurnBook, TurnPage } from 'turn-ts-react';
import 'turn-ts/turn-ts.css'; // required — the fold has no styles without it
export function Book() {
return (
<TurnBook width={840} height={560} display="double">
<TurnPage>Cover</TurnPage>
<TurnPage>Page two</TurnPage>
<TurnPage>Page three</TurnPage>
<TurnPage>Back</TurnPage>
</TurnBook>
);
}turn-ts/turn-ts.css is not optional and is not bundled into this package — it carries the rules
the fold needs, and just as importantly it leaves out the ones that break it (no overflow, no
contain, no perspective on the container). Import it once, anywhere, or copy its rules into
your own sheet.
Controlled
const [page, setPage] = useState(1);
<TurnBook width={840} height={560} page={page} onPageChange={setPage}>
{chapters.map((chapter) => (
<TurnPage key={chapter.id}>{chapter.body}</TurnPage>
))}
</TurnBook>;With page set the prop is the source of truth: it is re-asserted after every render, so a drag
that lands somewhere else is reported through onPageChange and then turned back unless you
accept it. Drop page and pass defaultPage instead for an uncontrolled book that just tracks
its own position.
"Already on page" means in view, not equal. A display="double" book shows a spread, so
page={4} and page={5} hold the same two leaves open and neither turns to the other. The engine
reports the leading page of the spread, which is what onPageChange gives you.
The imperative api
const book = useRef<TurnBookApi>(null);
<TurnBook ref={book} width={840} height={560}>…</TurnBook>;
book.current?.next();
book.current?.page(7);
book.current?.size(1024, 680);ref is the React 19 ref-as-a-prop, holding the live TurnBookApi — next, previous,
page, pages, view, range, size, display, configure, resize, update, disable,
stop, animating, hasPage, addPage, removePage, destroy. It is null before the mount
effect has run and null again after unmount. destroy is the wrapper's to call; calling it
yourself leaves the component holding a dead book until it unmounts.
TurnBook props
| Prop | Type | Notes |
| --- | --- | --- |
| width, height | number | Whole book in px — both leaves in display="double". Omit to size from the container. |
| page | number | Makes the book controlled. |
| defaultPage | number | Starting page for an uncontrolled book. Ignored when page is set. |
| display | 'single' \| 'double' | |
| gradients | boolean | |
| duration | number | Turn duration in ms. |
| cornerSize | number | Size of the grabbable corner hit area in px. |
| corners | 'backward' \| 'forward' \| 'all' \| ('tl'\|'tr'\|'bl'\|'br')[] | Which corners fold. |
| disabled | boolean | Refuses pointer folds. Turns through the ref still work. |
| ref | Ref<TurnBookApi> | The ref object's value is null before mount and after unmount. |
Events: onReady, onStart, onTurning, onTurn, onTurned, onFirst, onLast, plus
onPageChange(page, view) alongside onTurned and onInitError(error). All but onStart get a
TurnBookPageEvent of { type, api, page, view }; onStart additionally gets the corner that
was grabbed and a preventDefault() that refuses the fold.
None of those names collide with a native <div> handler — onError is the near miss, which is
why the initialization hook is onInitError. Everything else you pass (id, role, aria-*,
tabIndex, onKeyDown, className, style, data-*) lands on the container untouched.
If createTurnBook throws and you have not passed onInitError, the error is rethrown from the
effect and reaches your nearest error boundary. A book that silently did not build is worse.
TurnPage props
Renders the host-owned shape the engine documents:
<div class="turn-page-wrapper"> <!-- React's node. The engine styles it, never moves it. -->
<div class="turn-page"> <!-- The engine's node to fold. -->
<div class="turn-page-content"> <!-- Your children, clipped to the page box. -->The wrapper is the component's root, so it is the only node React ever inserts into or removes
from the book — which is what keeps a re-render mid-fold from throwing NotFoundError at a page
element the engine has lifted out. className, style, ref and every other prop land on the
.turn-page; wrapperClassName and wrapperStyle reach the wrapper.
Pages are read from the DOM in order, so key them the way you would key any list — insert one in
the middle and it becomes the middle page.
Styling and accessibility
Headless: the only class this package renders is .turn-book, and the only inline styles it
writes on the container are --turn-book-width and --turn-book-height. Colour, borders,
shadows and type are yours. Do not set width/height in the style prop — those belong to the
engine, and React would fight it for them on every render. Use the width/height props.
The book has no keyboard affordance of its own, because a page-turn is a visual convenience and the navigation belongs to your app. Give it one — the props pass straight through:
const book = useRef<TurnBookApi>(null);
<TurnBook
ref={book}
width={840}
height={560}
role="region"
aria-label="Field notes"
tabIndex={0}
onKeyDown={(event) => {
if (event.key === 'ArrowRight') book.current?.next();
if (event.key === 'ArrowLeft') book.current?.previous();
}}
>
…
</TurnBook>;Pages outside the current view are hidden with display: none, so assistive technology sees only
what is open. Give each TurnPage real headings and put anything that must always be reachable —
a table of contents, a "read as one page" fallback — outside the book.
Notes
createTurnBookruns in an effect, never during render. It touches the DOM immediately, so there is no SSR pass to worry about; the container renders sized from CSS custom properties and the fold arrives on mount.- StrictMode is fine. The mount/unmount/mount cycle destroys the first book before the second is built, which is exactly the contract the engine's one-book-per-element claim expects.
- The
pagesoption is not exposed. It truncates by callingremovePage, which throws for the host-owned wrappersTurnPagerenders. Render fewer children instead. - The engine re-measures itself on
pointerdownand through aResizeObserver. Callref.current.resize()by hand only if you defeat both.
Develop
npm install
npm run lint # typecheck + tests + build + "use client" check + publintTests run on node --test through tsx, with Happy DOM registered as the global DOM and React's
own act. Happy DOM has no layout engine, so fold geometry is not covered here — that is
turn-ts's problem. What is covered is this package's: mount timing, StrictMode, ref lifecycle,
controlled and uncontrolled paging, dynamic children, and unmounting mid-fold without a
NotFoundError.
License
Non-commercial. See LICENSE.md — turn.js 3rd release terms, inherited through
turn-ts.
