npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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

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-dist

npm ≥ 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/flipbook

That 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 the new URL(…, import.meta.url) a worker needs when it sits inside a route array. A small pdf-reader.providers.ts next to a loadChildren route 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-action follows the features you enabled. With zoomGestures on the viewport takes the whole gesture; with only swipeNavigation it 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