Skip to content

React Headless

Scope: React viewer toolkit — headless bindings (@scaryterry/pdfium/react/headless).

Use the headless surface when you want to own the product chrome yourself instead of theming the shipped PDFViewer or PDFEditor shells.

The boundary is deliberate:

  • @scaryterry/pdfium/react ships the opinionated viewer shell.
  • @scaryterry/pdfium/react/editor ships the opinionated editor shell.
  • @scaryterry/pdfium/react/headless/* ships provider bindings, state hooks, and unstyled page/interaction layers.

The flagship Vite showcase now makes that boundary explicit on the live routes too:

  • Viewer and Editor prove the shipped shell path themed by the --pdfium-* token system.
  • Headless Viewer and Headless Editor prove the public headless path with demo-owned chrome.
  • Theme builder is integrated into the showcase header: open it on any route to edit showcase and PDFium shell variables in real time, preview them against the active surface, save reusable packs, and import/export JSON or CSS theme packs.
  • The showcase mode + preset controls are shared across all four routes so you can see the same runtime under both shell theming and product-owned branding.
ModuleResponsibilityTypical use
react/headless/sessionProvider and document/session accessMount PDFium, access the active document
react/headless/viewerViewer state, contexts, and root composition primitivesNavigation, zoom, search, page state
react/headless/viewer-layersUnstyled page/layer primitivesPage canvas, text, search, links, annotations
react/headless/editorEditor provider and state hooksTool state, save, selection, free text, redaction
react/headless/editor-layersUnstyled editor overlaysSelection, drawing, free text, editor interaction
react/headlessConvenience barrel over all of the aboveSmall apps or quick prototypes

If you care about clear ownership boundaries, prefer the focused subpaths over the large barrel.

Headless does not mean “lower quality”. It means:

  • no shipped toolbar
  • no shipped panel sidebar
  • no shipped editor top bar, rail, or selected-action chrome
  • no dependency on the shell token system

You still get the same document/runtime behavior as the shipped shells because both paths now sit on the same headless session/viewer/editor ownership model.

The headless routes in the flagship demo are intentionally still wrapped in a branded app shell. That is not a contradiction; it is the point.

  • The shipped shell routes show that the public --pdfium-* token registry can theme the default viewer/editor product surfaces directly.
  • The headless routes show that you can theme your own cards, toolbars, buttons, and layout without depending on the shipped shell token system at all.

So when you see the showcase switch between light, dark, Default, Copper, and Atlas, read it this way:

  • shell routes: the preset themes the actual PDFium viewer/editor shell
  • headless routes: the preset themes the demo-owned chrome around the headless PDFium runtime

This is the smallest useful viewer composition path:

import { useState } from 'react';
import { PDFiumHeadlessProvider } from '@scaryterry/pdfium/react/headless/session';
import { useDocumentSearch, useViewerSetup } from '@scaryterry/pdfium/react/headless/viewer';
import { PDFDocumentView } from '@scaryterry/pdfium/react/headless/viewer-layers';
function HeadlessViewerSurface() {
const viewer = useViewerSetup({ initialFitMode: 'page-width' });
const [query, setQuery] = useState('');
const search = useDocumentSearch(viewer.document, query);
const searchState =
search.totalMatches > 0 || search.isSearching
? {
resultsByPage: search.resultsByPage,
currentIndex: search.currentIndex,
matchIndexMap: search.matchIndexMap,
currentMatchPageIndex: search.currentMatchPageIndex,
}
: undefined;
return (
<div style={{ display: 'grid', height: '100%', gridTemplateRows: 'auto 1fr', minHeight: 0 }}>
<div style={{ display: 'flex', gap: 8, padding: 12, borderBottom: '1px solid #e5e7eb' }}>
<button type="button" onClick={viewer.navigation.prev} disabled={!viewer.navigation.canPrev}>
Previous
</button>
<button type="button" onClick={viewer.navigation.next} disabled={!viewer.navigation.canNext}>
Next
</button>
<button type="button" onClick={viewer.fit.fitWidth}>
Fit width
</button>
<button type="button" onClick={viewer.fit.fitPage}>
Fit page
</button>
<input
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Search this document"
aria-label="Search this document"
/>
<button type="button" onClick={search.prev} disabled={search.totalMatches === 0}>
Previous match
</button>
<button type="button" onClick={search.next} disabled={search.totalMatches === 0}>
Next match
</button>
</div>
<PDFDocumentView
containerRef={viewer.container.ref}
zoomAnchorRef={viewer.container.zoomAnchorRef}
scrollMode={viewer.scroll.scrollMode}
scale={viewer.zoom.scale}
currentPageIndex={viewer.navigation.pageIndex}
onCurrentPageChange={viewer.navigation.setPageIndex}
getRotation={viewer.rotation.getRotation}
spreadMode={viewer.spread.spreadMode}
search={searchState}
style={{ height: '100%' }}
/>
</div>
);
}
function App() {
return (
<PDFiumHeadlessProvider wasmUrl={wasmUrl} workerUrl={workerUrl} initialDocument={{ data: pdfBytes, name: 'document.pdf' }}>
<HeadlessViewerSurface />
</PDFiumHeadlessProvider>
);
}

This is the same pattern used by the flagship Vite Headless Viewer route.

Headless editor composition keeps the viewer state and editor state separate on purpose:

import {
PDFiumHeadlessEditorProvider,
useEditorSave,
useEditorTool,
} from '@scaryterry/pdfium/react/headless/editor';
import { PDFEditorInteractionLayer } from '@scaryterry/pdfium/react/headless/editor-layers';
import { usePDFDocument } from '@scaryterry/pdfium/react/headless/session';
import { useAnnotations, useViewerSetup } from '@scaryterry/pdfium/react/headless/viewer';
import { PDFDocumentView, type PageOverlayInfo } from '@scaryterry/pdfium/react/headless/viewer-layers';
function HeadlessEditorPageOverlay(info: PageOverlayInfo & { selectionEnabled: boolean }) {
const { document } = usePDFDocument();
const annotations = useAnnotations(document, info.pageIndex);
return (
<div style={{ position: 'absolute', inset: 0, zIndex: 40, pointerEvents: 'none' }}>
<PDFEditorInteractionLayer
pageIndex={info.pageIndex}
scale={info.scale}
originalHeight={info.originalHeight}
width={info.width}
height={info.height}
annotations={annotations.data ?? []}
annotationsPending={annotations.isLoading || annotations.isPlaceholderData}
document={document}
selectionEnabled={info.selectionEnabled}
pageBitmapIncludesAnnotations={false}
/>
</div>
);
}
function HeadlessEditorSurface() {
const viewer = useViewerSetup({ initialFitMode: 'page-width', initialInteractionMode: 'pointer' });
const { document } = usePDFDocument();
const { activeTool, setTool } = useEditorTool();
const { save, isDirty, isSaving } = useEditorSave(document);
return (
<div style={{ display: 'grid', height: '100%', gridTemplateRows: 'auto 1fr', minHeight: 0 }}>
<div style={{ display: 'flex', gap: 8, padding: 12, borderBottom: '1px solid #e5e7eb' }}>
<button type="button" onClick={() => setTool('idle')}>Select</button>
<button type="button" onClick={() => setTool('ink')}>Draw</button>
<button type="button" onClick={() => setTool('freetext')}>Text</button>
<button type="button" onClick={() => setTool('rectangle')}>Rectangle</button>
<button type="button" onClick={viewer.fit.fitWidth}>Fit width</button>
<button type="button" onClick={() => void save()} disabled={isSaving || !isDirty}>
Save
</button>
<span>Active tool: {activeTool}</span>
</div>
<PDFDocumentView
containerRef={viewer.container.ref}
zoomAnchorRef={viewer.container.zoomAnchorRef}
scrollMode={viewer.scroll.scrollMode}
scale={viewer.zoom.scale}
currentPageIndex={viewer.navigation.pageIndex}
onCurrentPageChange={viewer.navigation.setPageIndex}
getRotation={viewer.rotation.getRotation}
spreadMode={viewer.spread.spreadMode}
renderAnnotations={false}
showAnnotations={false}
renderPageOverlay={(info) => (
<HeadlessEditorPageOverlay
{...info}
selectionEnabled={viewer.interaction.mode === 'pointer'}
/>
)}
style={{ height: '100%' }}
/>
</div>
);
}
function App() {
return (
<PDFiumHeadlessProvider wasmUrl={wasmUrl} workerUrl={workerUrl} initialDocument={{ data: pdfBytes, name: 'document.pdf' }}>
<PDFiumHeadlessEditorProvider>
<HeadlessEditorSurface />
</PDFiumHeadlessEditorProvider>
</PDFiumHeadlessProvider>
);
}

This is the same composition pattern used by the flagship Vite Headless Editor route.

If you want the fastest path from proof to product, the Vite showcase already contains the reference compositions:

  • demo/vite/src/features/Viewer/HeadlessViewerShowcase.tsx is the public viewer state + layer contract with local toolbar and search chrome.
  • demo/vite/src/features/Editor/HeadlessEditorShowcase.tsx is the public editor provider + overlay-layer contract with local tool and save chrome.
  • demo/vite/src/features/Theme/ThemeLabShowcase.tsx is the live token workbench: it mounts shipped shell surfaces and headless surfaces against one editable theme draft, then lets you save custom packs for reuse.

Use those files as the copy-paste starting point, not the flagship PDFViewer or PDFEditor routes, if your real goal is product-owned chrome.

If you build on the headless path, keep these rules:

  1. Own chrome in your app, not inside PDFium wrappers.
  2. Use react/headless/* subpaths directly when the boundary matters.
  3. Treat the shipped token system as shell-only; do not make headless composition depend on it.
  4. Keep viewer concerns and editor concerns separate even when they render in the same page.

Use PDFViewer or PDFEditor instead when:

  • you want the shipped chrome
  • tokens/slots are enough
  • you do not want to own toolbar/panel/action-bar product decisions

Use the headless path when product ownership matters more than drop-in convenience.