Skip to content

PdfViewer — the cross-framework PDF viewer

PdfViewer is Rozie's data-bound port of PDF.js (pdfjs-dist v6) — Mozilla's de-facto vanilla-JS PDF renderer. One .rozie source file ships idiomatic React, Vue, Svelte, Angular, Solid, and Lit consumers from a single wrapper. The per-framework ecosystem is lopsided: react-pdf (wojtekmaj) is deep and maintained for React; Vue (vue-pdf-embed), Angular (ng2-pdf-viewer) and Svelte have thinner / older options — and Solid and Lit have effectively nothing. Rozie collapses all six into one source, so the five underserved frameworks get a real embeddable PDF viewer — with selectable text, page navigation, zoom and rotation — for free. See the PDF libraries comparison for the full per-framework matrix.

This page is the show-and-tell: the API surface, per-framework quick starts, the five lifecycle events, the two-way page model, the 12-verb imperative handle, the worker / standard-font / text-layer story, and the recipes for continuous scroll, binary sources, and bundling the worker.

The full source for PdfViewer.rozie lives in the @rozie-ui/pdf package.

The @rozie-ui/pdf packages

PdfViewer ships as six pre-compiled, per-framework packages generated from a single PdfViewer.rozie source via the package's codegen.mjs doc-automation engine. Consumers install only the one for their framework — no Rozie toolchain, no build-time compile step:

PackageInstallREADME
@rozie-ui/pdf-reactnpm i @rozie-ui/pdf-reactreact/README
@rozie-ui/pdf-vuenpm i @rozie-ui/pdf-vuevue/README
@rozie-ui/pdf-sveltenpm i @rozie-ui/pdf-sveltesvelte/README
@rozie-ui/pdf-angularnpm i @rozie-ui/pdf-angularangular/README
@rozie-ui/pdf-solidnpm i @rozie-ui/pdf-solidsolid/README
@rozie-ui/pdf-litnpm i @rozie-ui/pdf-litlit/README

Each package carries the pdfjs-dist engine peer (^6) plus its framework peer (react + react-dom, vue, svelte, @angular/core + @angular/common, solid-js, or lit + @lit-labs/preact-signals + @preact/signals-core). Install the engine peer alongside the framework package:

bash
npm i @rozie-ui/pdf-react pdfjs-dist

Unlike MapLibre or Cropper, there is no separate engine-CSS importPdfViewer ships PDF.js's selectable-text-layer CSS itself (through the :root { } engine-DOM escape hatch). The PDF.js worker is also auto-configured from a version-matched jsDelivr CDN, so the component works with zero config out of the box — override the workerSrc prop only for offline / CSP / bundled-worker setups (see Gotchas). Anything the curated prop surface doesn't special-case (cMap URLs, HTTP headers, credentials, …) comes through the first-class :options passthrough — PDF.js's own getDocument DocumentInitParameters. The per-leaf READMEs and the Props table below are generated from the same IR parse of PdfViewer.rozie, so they cannot drift from the compiled output — the package's codegen.mjs asserts the structural columns of this page against ir.props on every run.

Quick start

The current page is two-way bound through the single page model prop (1-based). In single-page mode it drives which page renders (with nextPage / prevPage / goToPage); in render-all-pages mode it reflects the scrolled-to page (and scrolls when the consumer writes it). The PDF source comes through src — a URL string, a data: base64 URL, or binary data (ArrayBuffer / Uint8Array). Load / page / render lifecycle fires as native framework events.

React

tsx
import { useState } from 'react';
import { PdfViewer } from '@rozie-ui/pdf-react';

export function Demo() {
  const [page, setPage] = useState(1);
  return (
    <div style={{ height: 600 }}>
      <PdfViewer
        src="/document.pdf"
        page={page}
        onPageChange={(e) => setPage(e.page)}
        scale={1.2}
        render-all-pages
        onLoad={(e) => console.log(e.numPages)}
      />
    </div>
  );
}

Vue

vue
<script setup lang="ts">
import { ref } from 'vue';
import PdfViewer from '@rozie-ui/pdf-vue';

const page = ref(1);
</script>

<template>
  <div style="height: 600px">
    <PdfViewer
      src="/document.pdf"
      v-model:page="page"
      :scale="1.2"
      render-all-pages
      @load="(e) => console.log(e.numPages)"
    />
  </div>
</template>

Svelte

svelte
<script lang="ts">
  import PdfViewer from '@rozie-ui/pdf-svelte';

  let page = $state(1);
</script>

<div style="height: 600px">
  <PdfViewer
    src="/document.pdf"
    bind:page
    scale={1.2}
    render-all-pages
    onload={(e) => console.log(e.numPages)}
  />
</div>

Angular

ts
import { Component } from '@angular/core';
import { PdfViewer } from '@rozie-ui/pdf-angular';

@Component({
  selector: 'app-demo',
  standalone: true,
  imports: [PdfViewer],
  template: `
    <div style="height: 600px">
      <PdfViewer
        src="/document.pdf"
        [(page)]="page"
        [scale]="1.2"
        render-all-pages
        (load)="onLoad($event)"
      />
    </div>
  `,
})
export class DemoComponent {
  page = 1;
  onLoad(e: any) { console.log(e.numPages); }
}

Because page is the lone two-way model, the Angular component is a real ControlValueAccessor[(ngModel)]="page" and reactive formControl bindings work out of the box.

Solid

tsx
import { createSignal } from 'solid-js';
import { PdfViewer } from '@rozie-ui/pdf-solid';

export function Demo() {
  const [page, setPage] = createSignal(1);
  return (
    <div style={{ height: '600px' }}>
      <PdfViewer
        src="/document.pdf"
        page={page()}
        onPageChange={(e) => setPage(e.page)}
        scale={1.2}
        render-all-pages
        onLoad={(e) => console.log(e.numPages)}
      />
    </div>
  );
}

Lit

ts
import '@rozie-ui/pdf-lit';

// <rozie-pdf-viewer> is a custom element. Bind `src`/`page` as properties and
// listen for `page-change` (the two-way change channel) + `load`.
const el = document.querySelector('rozie-pdf-viewer');
el.src = '/document.pdf';
el.scale = 1.2;
el.addEventListener('page-change', (e) => { el.page = e.detail.page; });
el.addEventListener('load', (e) => console.log(e.detail.numPages));

API

Props

page is the lone two-way model prop (bind with r-model / v-model / bind: / [(…)] / onPageChange). scale and rotation are one-way props the imperative handle verbs override (the live render is driven by internal state seeded from the props, so zoomIn() / rotateCW() work whether or not the consumer binds them). All render-affecting props reconcile into the live view on change — no remount.

NameTypeDefaultTwo-way (model)Runtime-updatable?Description
srcunknownundefinedThe PDF source — a URL string, a data: base64 URL, or binary data (ArrayBuffer / Uint8Array). Changing it tears down the previous document (via its loading task) and loads the new one. undefined renders an empty viewer.
pageNumber1The 1-based current page. Two-way. In single-page mode it drives which page renders; in render-all-pages mode it reflects the scrolled-to page (and scrolls the container when the consumer writes it). Clamped to [1, pageCount].
scaleNumber1The zoom scale (1 = 100%). One-way: the setScale / zoomIn / zoomOut / fitWidth / fitPage handle verbs override it imperatively. A consumer write reconciles the live render.
rotationNumber0Rotation in degrees (0 / 90 / 180 / 270). One-way: the rotateCW / rotateCCW verbs override it. Normalized into [0, 360).
workerSrcString"https://cdn.jsdelivr.net/npm/pdfjs-dist@6.0.227/build/pdf.worker.min.mjs"The PDF.js worker URL. Set on GlobalWorkerOptions.workerSrc before loading. Defaults to the version-matched jsDelivr CDN so the component works with zero config; override for offline / CSP / a bundled worker (see Gotchas).
standardFontDataUrlString"https://cdn.jsdelivr.net/npm/pdfjs-dist@6.0.227/standard_fonts/"The directory of PDF.js's standard-font data so the base-14 fonts (Helvetica / Times / Courier / …) render with correct glyphs. Version-matched CDN default; override (or pass a bundled dir) for offline / CSP.
renderAllPagesBooleanfalsefalse = single page with nav (the two-way page drives it). true = continuous scroll of every page; the most-visible page reflects back into page and the pagechange event via an IntersectionObserver.
textLayerBooleantrueRender PDF.js's selectable / copyable text-layer spans over each page canvas (the differentiator vs a dumb canvas image). The required .textLayer CSS + --scale-factor var ship with the component — no extra import.
passwordunknownundefinedPassword for an encrypted PDF. If the document is encrypted and no (or a wrong) password is set, the passwordrequest event fires with { reason }. Changing it reloads the document.
optionsObject{}Raw getDocument DocumentInitParameters passthrough — spread before the curated keys (explicit src / password win). For cMapUrl, httpHeaders, withCredentials, etc.

Events

PDF.js's load / page / render / find lifecycle is forwarded as seven structured events:

EventPayloadFires when
load{ numPages }The document finished loading (after getDocument(...).promise resolves). Carries the total page count.
progress{ loaded, total }The document download advanced — loaded / total are bytes (PDF.js's onProgress). total may be 0 (or undefined) when the server omits a Content-Length header — passed through as-is.
errorthe engine ErrorA load or page-render failed (bad source, network error, corrupt PDF). Stale-load aborts are suppressed (a src change mid-load doesn't fire a spurious error).
pagechange{ page }The current page changed — driven by goToPage / nextPage / prevPage, a page-prop write, or (in render-all-pages) the scroll spy. Also drives the two-way page model.
pagesrenderedThe visible page(s) finished rendering (canvas + optional text layer) into the container.
passwordrequest{ reason }The document is encrypted and the supplied password is missing or wrong. reason is PDF.js's PasswordResponses code (need vs. incorrect).
findresult{ query, matches, current }A find ran (find / findNext / findPrev / clearFind). query is the active lowercased query ('' when cleared), matches is the total occurrence count across all pages, and current is the 1-based index of the active match (0 when none).

Imperative handle

Beyond props, the component exposes 19 imperative methods declared once in the Rozie source via $expose. Grab a handle with your framework's native ref mechanism (React useRef / Vue template ref / Svelte bind:this / Angular viewChild / Solid callback ref / the Lit custom element itself) and call them directly:

MethodDescription
getDocumentReturn the underlying pdfjs PDFDocumentProxy for direct API access (the engine escape hatch), or null before the document loads.
getPageCountReturn the total number of pages in the loaded document, or 0 before it loads.
goToPageNavigate to a 1-based page (clamped to [1, pageCount]) — goToPage(n).
nextPageAdvance to the next page (clamped at the last page).
prevPageGo back to the previous page (clamped at the first page).
setScaleSet the zoom scale to an absolute value (1 = 100%) — setScale(s).
zoomInZoom in by one step (×1.25, capped at 10×).
zoomOutZoom out by one step (÷1.25, floored at 0.1×).
fitWidthFit the current page to the container width.
fitPageFit the current page entirely within the container (width and height).
rotateCWRotate the view 90° clockwise.
rotateCCWRotate the view 90° counter-clockwise.
downloadDownload the original PDF bytes — download(filename?) (defaults to document.pdf). Resolves true on success, false before the document loads.
getMetadataResolve the document metadata (title, author, page labels, …) — pdfjs PDFDocumentProxy.getMetadata(). null before load.
getOutlineResolve the document outline (bookmark / table-of-contents tree) for a navigation sidebar — pdfjs getOutline(). null when absent or before load.
findSearch the whole document for a query — find(query). Scans every page's text, navigates to + highlights the first match, returns a Promise resolving to the match count, and emits findresult. The highlight is coarse / span-level: it highlights whole text-layer spans that contain the query — a query that straddles two spans won't highlight.
findNextAdvance to the next match (wraps around), navigating its page + re-emitting findresult with the new current. No-op before a find.
findPrevGo back to the previous match (wraps around), navigating its page + re-emitting findresult. No-op before a find.
clearFindClear the active query + highlights, re-render, and emit findresult with { query: '', matches: 0, current: 0 }.

Why navigation is goToPage, not setPage

The handle navigates with goToPage(n) — there is deliberately no setPage verb. page is the two-way model prop, so React auto-generates an internal setPage setter; a setPage handle verb would collide with it (ROZ524). None of the 19 verbs collides with an emitted event name either (no bare load / error / pagechange / pagesrendered / passwordrequest / progress / findresult — ROZ121), and none shadows a LitElement lifecycle method. Every verb drives the component's internal render state (not the props), so it works whether or not the consumer two-way-binds page — only page mirrors back through the model; scale / rotation are one-way props the verbs override imperatively.

React example:

tsx
import { useRef } from 'react';
import { PdfViewer, type PdfViewerHandle } from '@rozie-ui/pdf-react';

const viewer = useRef<PdfViewerHandle>(null);
// <PdfViewer ref={viewer} ... />
viewer.current?.nextPage();
const total = viewer.current?.getPageCount();
viewer.current?.fitWidth();
const pdf = viewer.current?.getDocument();   // the raw pdfjs PDFDocumentProxy

Recipes

Drive the page two ways at once: bind page to your component state, and call the handle's nextPage / prevPage / goToPage. They stay in sync — the verbs write the internal page, which echoes back into the bound page and fires pagechange:

tsx
import { useRef, useState } from 'react';
import { PdfViewer, type PdfViewerHandle } from '@rozie-ui/pdf-react';

export function Demo() {
  const viewer = useRef<PdfViewerHandle>(null);
  const [page, setPage] = useState(1);
  const [total, setTotal] = useState(0);
  return (
    <div>
      <button onClick={() => viewer.current?.prevPage()} disabled={page <= 1}>Prev</button>
      <span>{page} / {total}</span>
      <button onClick={() => viewer.current?.nextPage()} disabled={page >= total}>Next</button>
      <div style={{ height: 600 }}>
        <PdfViewer
          ref={viewer}
          src="/document.pdf"
          page={page}
          onPageChange={(e) => setPage(e.page)}
          onLoad={(e) => setTotal(e.numPages)}
        />
      </div>
    </div>
  );
}

Zoom & rotate via the handle

scale and rotation are one-way props, so drive them imperatively. The zoom verbs step relative to the current scale (zoomIn ×1.25 capped at 10×, zoomOut ÷1.25 floored at 0.1×); setScale(s) sets an absolute value; fitWidth / fitPage measure the container:

ts
viewer.zoomIn();
viewer.zoomOut();
viewer.setScale(1.5);     // 150%
viewer.fitWidth();        // fit the page to the container width
viewer.fitPage();         // fit the whole page in view
viewer.rotateCW();        // 90° clockwise
viewer.rotateCCW();       // 90° counter-clockwise

Continuous-scroll mode

Set render-all-pages to render every page in one scrollable column. The most-visible page reflects back into the two-way page (and the pagechange event) via an IntersectionObserver, and a consumer write to page scrolls that page into view:

vue
<script setup lang="ts">
import { ref } from 'vue';
import PdfViewer from '@rozie-ui/pdf-vue';

const page = ref(1);
</script>

<template>
  <p>On page {{ page }}</p>
  <div style="height: 80vh">
    <PdfViewer src="/document.pdf" v-model:page="page" render-all-pages />
  </div>
</template>

Selectable text

The text layer is on by defaulttextLayer renders PDF.js's selectable / copyable text spans over each page canvas, so users can select, copy, and (with the browser's find) search the rendered text. No extra CSS import is needed; the component ships the .textLayer styles itself. Disable it for a pure-image render (faster, no selection):

svelte
<PdfViewer src="/document.pdf" textLayer={false} />

Loading a Uint8Array / ArrayBuffer

src accepts binary data, not just a URL — pass an ArrayBuffer or Uint8Array (e.g. from a fetch, a File drop, or an upload). The wrapper feeds it straight into PDF.js's getDocument({ data }):

tsx
import { useEffect, useState } from 'react';
import { PdfViewer } from '@rozie-ui/pdf-react';

export function Demo({ file }: { file: File }) {
  const [bytes, setBytes] = useState<ArrayBuffer>();
  useEffect(() => { file.arrayBuffer().then(setBytes); }, [file]);
  return (
    <div style={{ height: 600 }}>
      {bytes && <PdfViewer src={bytes} />}
    </div>
  );
}

A data:application/pdf;base64,… URL string also works — the wrapper decodes it to bytes for you (PDF.js's url path doesn't fetch data URLs).

Overriding the worker for a bundler

The default workerSrc is the version-matched CDN. To bundle the worker with your app (offline, CSP, or to avoid the CDN round-trip), point workerSrc at the worker resolved by your bundler. With Vite:

vue
<script setup lang="ts">
import PdfViewer from '@rozie-ui/pdf-vue';

// Vite resolves the worker asset URL at build time.
const workerSrc = new URL(
  'pdfjs-dist/build/pdf.worker.min.mjs',
  import.meta.url,
).toString();
</script>

<template>
  <PdfViewer src="/document.pdf" :worker-src="workerSrc" />
</template>

For fully-offline rendering, also bundle the standard fonts and point standardFontDataUrl at the bundled directory (otherwise the base-14 fonts still hit the CDN).

Gotchas

The PDF.js worker

PDF.js parses documents in a Web Worker, so GlobalWorkerOptions.workerSrc must be set before getDocument. The workerSrc prop defaults to the version-matched jsDelivr CDN copy, so the component works with zero config — but that default makes a network request to the CDN on first load. For offline apps, a strict CSP (the CDN URL must be in worker-src / script-src), or to avoid the round-trip, override workerSrc with a bundled worker (see the Vite recipe above). The worker version must match the pdfjs-dist version your app resolves — the default URL is pinned to the engine version this package was built against; if you upgrade pdfjs-dist, override workerSrc to the matching version.

standardFontDataUrl for correct glyphs

PDF.js doesn't embed the base-14 standard fonts (Helvetica / Times / Courier / Symbol / ZapfDingbats). Without standardFontDataUrl, documents that rely on them render with substituted glyphs and console warnings. The prop defaults to the version-matched CDN so the fonts "just work" online; for offline / CSP, bundle the pdfjs-dist/standard_fonts/ directory and point standardFontDataUrl at it.

The container needs a height

PDF.js renders into the component's .rozie-pdf host, which is width: 100%; height: 100%; min-height: 320px; overflow: auto. Give the parent an explicit height (the quick-start examples wrap the viewer in a 600px-tall <div>) — especially in render-all-pages mode, where the scroll spy and scrollIntoView need a scrollable, sized container. A zero-height parent collapses to the 320px minimum.

CORS for cross-origin PDF URLs

When src is a URL on another origin, the fetch is subject to CORS — the PDF server must send permissive Access-Control-Allow-Origin headers, or the load fails and the error event fires. Same-origin URLs, data: URLs, and binary ArrayBuffer / Uint8Array sources have no CORS constraint. For authenticated cross-origin fetches, pass withCredentials / httpHeaders through the :options bag.

Cross-references

Pre-v1.0 — internal monorepo.