turn-ts-vue
v0.1.1
Published
Vue 3.5 bindings for turn-ts. A page-flip book component with v-model paging and a typed imperative api.
Maintainers
Readme
turn-ts-vue
Vue 3.5 bindings for turn-ts, the framework-agnostic
page-flip engine. Two components, v-model:page, typed emits, and the live engine on a template
ref.
[!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-vuevue ^3.5 is a peer dependency. turn-ts is a direct dependency — you do not install it
yourself, but you do import its stylesheet.
Use
<script setup lang="ts">
import { TurnBook, TurnPage } from 'turn-ts-vue';
import 'turn-ts/turn-ts.css'; // required — the fold has no styles without it
</script>
<template>
<TurnBook :width="840" :height="560" display="double">
<TurnPage>Cover</TurnPage>
<TurnPage>Page two</TurnPage>
<TurnPage>Page three</TurnPage>
<TurnPage>Back</TurnPage>
</TurnBook>
</template>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.
Paging with v-model
<script setup lang="ts">
import { ref } from 'vue';
import { TurnBook, TurnPage } from 'turn-ts-vue';
const page = ref(1);
</script>
<template>
<TurnBook v-model:page="page" :width="840" :height="560">
<TurnPage v-for="chapter in chapters" :key="chapter.id">{{ chapter.body }}</TurnPage>
</TurnBook>
<p>Page {{ page }}</p>
</template>Set the model and the book turns there; drag the book and the model follows once the turn lands.
Bind :page without the model and it still works as the starting page and as a programmatic
target — the book just has nowhere to report back to. defaultPage is the same starting-page role
for code that never wants to touch page at all.
"Already on page" means in view, not equal. A display="double" book shows a spread, so 4
and 5 hold the same two leaves open and neither turns to the other. The book only emits
update:page when the value it holds has gone off screen, so a model sitting on 5 is not
silently rewritten to 4 for a spread that never moved.
The imperative api
<script setup lang="ts">
import { useTemplateRef } from 'vue';
import { TurnBook, TurnPage, type TurnBookExposed } from 'turn-ts-vue';
const book = useTemplateRef<TurnBookExposed>('book');
</script>
<template>
<TurnBook ref="book" :width="840" :height="560">…</TurnBook>
<button type="button" @click="book?.api?.previous()">Previous</button>
<button type="button" @click="book?.api?.next()">Next</button>
</template>api is 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 onMounted has run and null again after
unmount. destroy is the component's to call; calling it yourself leaves the component holding a
dead book.
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 | The v-model:page value. Current page, 1-based. |
| defaultPage | number | Starting page when page has no value. |
| 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 api still work. |
Emits: ready, start, turning, turn, turned, first, last, plus update:page and
page-change(page, view) alongside turned, and init-error(error). All but start carry a
TurnBookPageEvent of { type, api, page, view }; start additionally carries the corner that
was grabbed and a preventDefault() that refuses the fold.
inheritAttrs is off and attributes are applied by hand, so declared emits never leak onto the
container. Everything else you pass (id, role, aria-*, tabindex, @keydown, class,
style, data-*) lands on it untouched.
If createTurnBook throws and nothing is listening for init-error, the error is rethrown from
onMounted and reaches your nearest onErrorCaptured. A book that silently did not build is
worse.
TurnPage props
Renders the host-owned shape the engine documents:
<div class="turn-page-wrapper"> <!-- Vue'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 slot content, clipped to the page box. -->The wrapper is the component's root, so it is the only node Vue 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. class, style and every other attribute land on the .turn-page;
wrapperClass and wrapperStyle reach the wrapper, and a template ref exposes element, the
.turn-page node.
Pages are read from the DOM in order, so key a v-for 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 on the container is .turn-book, and the only inline
styles it writes there are --turn-book-width and --turn-book-height. Colour, borders, shadows
and type are yours. Do not set width/height in style — those belong to the engine, and Vue
would fight it for them on every patch. 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 attributes pass straight through:
<TurnBook
ref="book"
:width="840"
:height="560"
role="region"
aria-label="Field notes"
tabindex="0"
@keydown.right="book?.api?.next()"
@keydown.left="book?.api?.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
- SSR is safe.
turn-tstouches no DOM at module scope, andonMounteddoes not run on the server. The container renders sized from CSS custom properties and the fold arrives on mount, so Nuxt andvue/server-rendererneed no<ClientOnly>wrapper around it. - Teardown runs in
onBeforeUnmount, ahead of Vue removing the container, so every page node the engine had lifted into its fold layer is back where Vue expects it before Vue detaches it. - The components are render functions, not SFCs. The published package is plain ESM + CJS with
.d.ts; nothing in your build or ours needs a.vuecompiler to consume it. - 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. Callapi.resize()by hand only if you defeat both.
Develop
npm install
npm run lint # typecheck + tests + build + publint + attwTests run on node --test through tsx, with Happy DOM registered as the global DOM and a real
createApp. 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, teardown, the exposed api,
v-model paging, option and disabled pushing, dynamic slot children, initialization failure
reporting, attribute pass-through, and unmounting mid-fold without a NotFoundError.
License
Non-commercial. See LICENSE.md — turn.js 3rd release terms, inherited through
turn-ts.
