@vigdx/flipbook
v1.1.0
Published
Angular PDF flipbook viewer with page-curl animation, tiled rendering, zoom, search and virtualized spreads, built on pdf.js
Maintainers
Readme
@vigdx/flipbook
An Angular PDF reader that turns like a book. Built on pdf.js, with a page-curl turn, tiled zoom, virtualised spreads and thumbnails, full-document search, and touch gestures — under a memory contract that tests enforce. A 200-page document never mounts 200 canvases.
- Turns under the thumb. Drag the page and it follows; let go part-way and it falls back. Flick and it turns. Touch and pen only — a mouse drag still selects text.
- Bounded memory. Fixed canvas pools and pixel-budgeted tile caches. Zooming raises sharpness, not tile area: above fit, only the visible sub-rectangle of a page is rasterised.
- Reads both ways. RTL mirrors the board and the gestures; reading order never changes.
- Nothing hardwired. Every colour and radius is a CSS custom property, every user-facing string is an input, and the toolbar is assembled from exported atoms you can rebuild with.
- Accessible by default. Keyboard throughout, focus managed across fullscreen and drawers, reduced-motion honoured over any input, AXE-clean.
| Component | What it is |
| --------------------------- | ------------------------------------------------- |
| <vig-flipbook-surface> | The book itself. Works alone with [(page)] |
| <vig-flipbook-toolbar> | Navigation, zoom, spread, save, print, fullscreen |
| <vig-flipbook-thumbnails> | Virtualised page rail |
| <vig-flipbook-search> | Search box with match stepping and highlights |
| <vig-flipbook-drawer> | Slide-over panel for chrome on small screens |
Standalone components throughout: signals, OnPush, zoneless-ready.
Requirements
| | | | ------------------------------------ | -------------------------------------------------------------------------------------------------- | | Angular | 22 or later | | Tailwind CSS | v4 — the components are styled with Tailwind utilities, so a v4 pipeline is required, not optional | | A place to put the pdf.js worker | Your build must be able to emit a Web Worker, or serve one. See Setup |
Install
npm i @vigdx/flipbook @angular/cdk pdfjs-distnpm ≥ 7 pulls peer dependencies in for you; with pnpm or yarn, install them explicitly as above.
| Peer | Version | Why |
| ----------------- | --------- | ------------------------------------------------------- |
| @angular/core | ^22.0.0 | Standalone components, signals |
| @angular/common | ^22.0.0 | NgTemplateOutlet, for the replaceable state templates |
| @angular/cdk | ^22.0.0 | Reduced-motion detection, drawer focus trap |
| pdfjs-dist | ^6.1.0 | Rendering engine, reached only through a dynamic import |
pdfjs-dist never lands in your initial bundle, and there is no UI-kit dependency: the viewer
ships its own design tokens and stands alone whatever design system you run.
Setup
ng add @vigdx/flipbookThat writes the worker entry point, adds the four pdf.js asset globs to your build target and
imports the tokens into your global stylesheet — the three mechanical steps, in both Angular CLI
and Nx workspaces. It leaves one thing to you, and says so when it finishes: where
provideFlipbook goes, because that decides whether the viewer ends up in a lazy chunk and only
you know which route should own it.
The rest of this section is what ng add does, for anyone wiring it by hand or checking its
work. It is the recommended path for an application built with @angular/build; see
Other build setups if that is not you.
1. Styles
In your global stylesheet:
@import 'tailwindcss';
@import '@vigdx/flipbook/theme.css';
/* Let Tailwind see the classes used inside the package: */
@source '../node_modules/@vigdx/flipbook';Adjust the @source path to where your stylesheet lives.
2. The pdf.js worker
pdf.js parses documents in a Web Worker, and the host application has to supply it — a
published library cannot. ng-packagr does not bundle workers, and @angular/build's worker
transformer only runs over your own TypeScript, resolving specifiers with a plain path.join, so
a bare specifier silently produces a broken URL.
Create a one-line src/pdf.worker.ts, free of Angular imports:
import 'pdfjs-dist/build/pdf.worker.min.mjs';Then provide the factory with a relative specifier, at the route level so the library and its configuration land in the lazy chunk:
// flipbook.routes.ts, loaded via loadChildren
import { provideFlipbook } from '@vigdx/flipbook';
export const flipbookRoutes: Route[] = [
{
path: '',
providers: [
provideFlipbook({
createWorker: () =>
new Worker(new URL('../pdf.worker', import.meta.url), { type: 'module' }),
}),
],
component: MyViewerPage,
},
];3. pdf.js runtime assets
pdf.js fetches CJK character maps, the 14 standard fonts, WASM codecs and ICC profiles on demand. Simple Latin-script documents render without them; real-world documents do not.
// angular.json / project.json → build.options.assets
{ "glob": "**/*", "input": "node_modules/pdfjs-dist/cmaps", "output": "pdfjs/cmaps" },
{ "glob": "**/*", "input": "node_modules/pdfjs-dist/standard_fonts", "output": "pdfjs/standard_fonts" },
{ "glob": "**/*", "input": "node_modules/pdfjs-dist/wasm", "output": "pdfjs/wasm" },
{ "glob": "**/*", "input": "node_modules/pdfjs-dist/iccs", "output": "pdfjs/iccs" }provideFlipbook({
createWorker: /* … */,
cMapUrl: 'pdfjs/cmaps/',
standardFontDataUrl: 'pdfjs/standard_fonts/',
wasmUrl: 'pdfjs/wasm/',
iccUrl: 'pdfjs/iccs/',
});Two traps worth naming. Keep
provideFlipbook(…)out of a route object literal in a file that is not itself lazily loaded: importing it from your root route table pulls the viewer into the initial bundle, and Angular's lazy-route transformer also chokes on thenew URL(…, import.meta.url)a worker needs when it sits inside a route array. A smallpdf-reader.providers.tsnext to aloadChildrenroute file avoids both.
Server-side rendering
Nothing to do. On the server the viewer renders its waiting state and starts
nothing — no worker, no canvas, no measurement — and comes alive on hydration.
You do not need to wrap it in @defer or guard it with isPlatformBrowser.
Quick start
<vig-flipbook-surface [src]="'/assets/catalog.pdf'" [(page)]="page" />Give the host element a height — the book fits itself into the box it is given.
The full viewer
One controller ties the surface to its chrome. Provide it once, then compose freely: the toolbar can sit above the book, below it, or float over it.
import {
FlipbookSurface,
FlipbookToolbar,
FlipbookThumbnails,
FlipbookSearch,
provideFlipbookViewer,
} from '@vigdx/flipbook';
@Component({
imports: [FlipbookSurface, FlipbookToolbar, FlipbookThumbnails, FlipbookSearch],
providers: [provideFlipbookViewer()],
template: `
<vig-flipbook-toolbar [showDownload]="true" [showPrint]="true" [showFullscreen]="true" />
<vig-flipbook-search />
<div class="flex min-h-0 flex-1 gap-3">
<vig-flipbook-thumbnails class="w-36 flex-none" />
<vig-flipbook-surface class="min-h-0 flex-1" [src]="src" />
</div>
`,
})
export class Viewer {}The injected FlipbookController is also your programmatic API: goTo(page), next(),
previous(), setZoom('fit-width'), toggleSpread(), plus signals for page, numPages,
resolvedZoom and more.
Phones and tablets
One component at every size — there is no separate mobile build. A reader that fills the screen with no chrome at all is a composition:
<vig-flipbook-surface class="h-full" style="--vig-flipbook-min-height: 0" [src]="src" />- Swipe and drag to turn. On by default for touch and pen (
swipeNavigation). A drag moves the paper with the finger; a flick has to travel 10% of the viewport (at least 40px), be decisively sideways, and land inside 800ms. Anything vaguely vertical is left to the browser, so a book embedded in a scrolling page can still be scrolled past. - Pinch to zoom, drag to pan. While zoomed a drag pans instead of turning — that is the only way to reach the parts of a magnified page that are off screen.
- Single page, automatically.
spreadMode: 'auto'measures the container, not the screen, so a phone gets one page and a tablet gets a spread without a media query. touch-actionfollows the features you enabled. WithzoomGestureson the viewport takes the whole gesture; with onlyswipeNavigationit keeps the horizontal axis and leaves vertical scrolling to the page.
The page rail has nowhere to live on a small screen, so put it in a drawer:
<button (click)="pagesOpen.set(true)">Pages</button>
<vig-flipbook-drawer [(open)]="pagesOpen" side="bottom" label="Pages">
<vig-flipbook-thumbnails
class="h-full"
orientation="horizontal"
(pageSelect)="pagesOpen.set(false)"
/>
</vig-flipbook-drawer>The drawer is a modal dialog while open — focus trapped, escape and backdrop close it, focus
returned to whatever opened it. side is 'start' | 'end' | 'bottom'; size and stacking are
tokens (--vig-flipbook-drawer-size, --vig-flipbook-drawer-z). Its content is projected, so it
is not tied to the thumbnail rail.
Saving and printing
The viewer renders to canvases, so chrome needs a route to the file itself. The surface publishes one, and the toolbar offers both actions against it:
<vig-flipbook-toolbar [showDownload]="true" [showPrint]="true" />Ctrl/Cmd+P prints the document rather than the page around it, the way a browser's own PDF
viewer does. Chrome you build yourself can call the same two functions the toolbar does:
import { downloadDocument, printDocument } from '@vigdx/flipbook';
const file = controller.file();
if (file) await printDocument(file);Sources given as bytes or a Blob become an object URL, minted only when one of these is first
used and released when the document is swapped. Printing goes through a hidden iframe so the
reader stays on the page; a PDF served from another origin cannot be driven that way and
opens in a new tab for the browser's own viewer to print instead.
Theming
Every token is a plain CSS custom property, so you can retheme globally in a @theme block, per
subtree with a wrapper style, or per instance:
@theme {
--color-flipbook-surface: #0f1115;
--color-flipbook-page: #1b1f27;
--radius-flipbook: 0;
}The namespaces are --color-flipbook-*, --radius-flipbook-* and --shadow-flipbook-*. Five of
them defer to a host design system when one is present and fall back to their own defaults
otherwise — define the variable in the "Follows" column anywhere in your CSS and the viewer picks
it up:
| Flipbook token | Follows | Standalone default |
| ------------------------------ | ----------------- | ------------------ |
| --color-flipbook-focus | --color-primary | #2b5ce6 |
| --color-flipbook-error | --color-error | #ce3b41 |
| --color-flipbook-placeholder | --color-muted | #e6e9ef |
| --radius-flipbook-control | --radius-button | 0.375rem |
| --radius-flipbook-field | --radius-input | 0.375rem |
Customising
Replacing the loading and error states
Both are defaults, not fixtures:
<vig-flipbook-surface [src]="src" [errorTemplate]="failed" [loadingTemplate]="waiting" />
<ng-template #failed let-error>
<p role="alert">{{ error.message }}</p>
<button (click)="retry()">Try again</button>
</ng-template>
<ng-template #waiting>
<my-spinner />
</ng-template>The error template receives the FlipbookError as $implicit. Keep a role="alert" on it — the
built-in has one, and a replacement that drops it leaves a screen reader unaware the document
failed.
Building your own chrome
<vig-flipbook-toolbar> and <vig-flipbook-search> are arrangements, not the only ones. The
pieces they are made of are exported, and all of them talk to the same controller:
| Atom | What it is |
| ------------------------------- | ---------------------------------------------------------------- |
| <vig-flipbook-icon name="…"> | A 1em currentColor glyph. Names in FLIPBOOK_ICON_NAMES |
| button[vigFlipbookIconButton] | Chrome styling on a native button — ="wide" for a labelled one |
| <vig-flipbook-page-field> | "Page n of N", emits pageChange |
| <vig-flipbook-zoom-select> | Zoom readout and presets, emits zoomChange |
| <vig-flipbook-toolbar-group> | A cluster of controls with the separating rule |
<div role="toolbar" aria-label="Reader">
<vig-flipbook-toolbar-group>
<button vigFlipbookIconButton aria-label="Previous page" (click)="controller.previous()">
<vig-flipbook-icon name="previous" />
</button>
<button vigFlipbookIconButton aria-label="Next page" (click)="controller.next()">
<vig-flipbook-icon name="next" />
</button>
</vig-flipbook-toolbar-group>
</div>Every user-facing string on every component is an input, so all of it can be localised.
API
<vig-flipbook-surface> inputs
| Input | Type | Default | Notes |
| --------------------- | ---------------------------------------------------- | ------------------- | ---------------------------------------------------------------------------------------------------------------- |
| src (required) | string \| URL \| ArrayBuffer \| Uint8Array \| Blob | — | Binary sources are copied before transfer, so your buffer is not detached |
| page | model<number> | 1 | Two-way bindable |
| spreadMode | 'auto' \| 'single' \| 'double' (model) | 'auto' | auto resolves against the measured width |
| coverPage | boolean | true | Cover stands alone, book convention |
| zoom | 'fit-page' \| 'fit-width' \| number (model) | 'fit-page' | Numbers are explicit factors; 1 = fit |
| minZoom / maxZoom | number | 1 / 4 | |
| zoomGestures | boolean | true | ctrl+wheel, pinch, drag-pan, double-click |
| swipeNavigation | boolean | true | Drag or flick to turn, on touch and pen. Never a mouse, never while zoomed |
| readingDirection | 'ltr' \| 'rtl' | 'ltr' | Mirrors the board; reading order never changes |
| textExtraction | 'all' \| 'visible' \| 'none' | 'all' | Search scope. Set 'none' when you offer no search: it drops the worker round-trips and the text cache entirely |
| password | string \| undefined | undefined | For encrypted documents; a 'password' loadError asks for one |
| animate | boolean | true | Reduced-motion always wins |
| animator | 'css' \| 'webgl' \| 'none' | 'css' | webgl renders a real paper curl |
| flipDuration | number (ms) | 620 | |
| breakpoint | number (px) | 768 | auto spread threshold |
| renderAhead | number | 1 | Spreads pre-rendered ahead of travel |
| maxBookWidth | number (px) | 1100 | |
| maxDevicePixelRatio | number | 2 | A 3× screen gains little and costs 2.25× |
| viewerLabel | string | 'Document viewer' | aria-label; override for i18n |
| autoFocus | boolean | false | Take the keyboard on load. Turn on when the viewer is the page |
| errorTemplate | TemplateRef | — | Replaces the failure panel; receives the error |
| loadingTemplate | TemplateRef | — | Replaces the page skeleton |
Outputs: documentLoad: FlipbookDocumentInfo, loadError: FlipbookError, plus the change
outputs of every model. Methods: next(), previous(), zoomIn(), zoomOut(),
resetZoom(), focus().
Keyboard
| Keys | Does |
| ------------------- | ------------------------------------------ |
| ← → | Turn the page — or pan, while zoomed |
| ↑ ↓ | Pan, while zoomed |
| PageUp PageDown | Turn the page |
| Home End | First and last page |
| + - 0 | Zoom in, out, reset |
| Escape | Reset zoom, when zoomed |
| Ctrl/Cmd+P | Print the document, not the page around it |
Every other modified stroke is left alone, so the browser and your own shortcuts keep working.
The keys hang off the viewer's own focusable region, so it has to hold focus for them to fire.
Set autoFocus when the viewer is the whole page, or call focus() after a dialog opens or a
route settles. Fullscreen is handled for you: the control that triggered it is usually outside
the element that grew, so the viewer takes the keyboard back on the way in.
Memory & tuning
All ceilings live in one place inside the library and are asserted by tests, including a real-browser ring — they are deliberately not configurable, because every number is chosen against the others: pinned tiles can never exceed half a cache budget, pool sizes cover a mid-turn handoff, and so on. Zoom never grows tile memory; above fit only the visible sub-rectangle of a page is rasterised, so sharpness rises while tile area stays flat.
The one knob that is per-host is the device-pixel-ratio ceiling:
<vig-flipbook-surface [src]="src" [maxDevicePixelRatio]="2" />Testing your integration
The @vigdx/flipbook/testing entry point generates small deterministic PDFs in memory — no
binaries to commit:
import { createSamplePdf, createSamplePdfBlobUrl } from '@vigdx/flipbook/testing';
const bytes = createSamplePdf({ pages: 48, title: 'Fixture' });For jsdom unit tests, where there is no canvas and no Worker, the loadPdfjs config seam lets
you substitute a fake pdf.js so component specs never touch the real library.
Other build setups
webpack (Storybook, older CLI)
webpack 5 resolves bare specifiers inside new URL(…, import.meta.url), so no extra file is
needed:
provideFlipbook({
createWorker: () =>
new Worker(new URL('pdfjs-dist/build/pdf.worker.min.mjs', import.meta.url), {
type: 'module',
}),
});A worker you already serve
Copied to your assets, or from a CDN pinned to your installed pdfjs-dist version:
provideFlipbook({ workerSrc: '/pdf.worker.min.mjs' });createWorker is preferred over workerSrc: it is a factory, so the library can revive the
worker after pdf.js destroys the port — otherwise the second document never loads.
License
MIT © Burkan Akyurek
