React Headless
React Headless
Section titled “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/reactships the opinionated viewer shell.@scaryterry/pdfium/react/editorships 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:
ViewerandEditorprove the shipped shell path themed by the--pdfium-*token system.Headless ViewerandHeadless Editorprove the public headless path with demo-owned chrome.Theme builderis 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 + presetcontrols are shared across all four routes so you can see the same runtime under both shell theming and product-owned branding.
Module Map
Section titled “Module Map”| Module | Responsibility | Typical use |
|---|---|---|
react/headless/session | Provider and document/session access | Mount PDFium, access the active document |
react/headless/viewer | Viewer state, contexts, and root composition primitives | Navigation, zoom, search, page state |
react/headless/viewer-layers | Unstyled page/layer primitives | Page canvas, text, search, links, annotations |
react/headless/editor | Editor provider and state hooks | Tool state, save, selection, free text, redaction |
react/headless/editor-layers | Unstyled editor overlays | Selection, drawing, free text, editor interaction |
react/headless | Convenience barrel over all of the above | Small apps or quick prototypes |
If you care about clear ownership boundaries, prefer the focused subpaths over the large barrel.
What Headless Means Here
Section titled “What Headless Means Here”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.
Showcase Theme Story
Section titled “Showcase Theme Story”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
Minimal Headless Viewer
Section titled “Minimal Headless Viewer”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.
Minimal Headless Editor
Section titled “Minimal Headless Editor”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.
Lift The Showcase Routes Directly
Section titled “Lift The Showcase Routes Directly”If you want the fastest path from proof to product, the Vite showcase already contains the reference compositions:
demo/vite/src/features/Viewer/HeadlessViewerShowcase.tsxis the public viewer state + layer contract with local toolbar and search chrome.demo/vite/src/features/Editor/HeadlessEditorShowcase.tsxis the public editor provider + overlay-layer contract with local tool and save chrome.demo/vite/src/features/Theme/ThemeLabShowcase.tsxis 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.
Composition Rules
Section titled “Composition Rules”If you build on the headless path, keep these rules:
- Own chrome in your app, not inside PDFium wrappers.
- Use
react/headless/*subpaths directly when the boundary matters. - Treat the shipped token system as shell-only; do not make headless composition depend on it.
- Keep viewer concerns and editor concerns separate even when they render in the same page.
When To Use Shell Instead
Section titled “When To Use Shell Instead”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.
See also
Section titled “See also”- React setup for provider, worker, and WASM wiring
- PDFViewer for the shipped viewer shell
- Editor for the shipped editor shell
- Styling for the shell token story