@joelouf/doc-viewer
v1.2.0
Published
A modular, zero-dependency document page viewer built for displaying pre-rendered page images in a print-preview-style interface.
Maintainers
Readme
@joelouf/doc-viewer
A modular, zero-dependency document page viewer built for displaying pre-rendered page images in a print-preview-style interface. The core logic ships as framework-agnostic modules consumed by both a Web Component and React hooks, including:
- Zoom math
- Image loading
- Virtualization
- Reduced-motion detection
This component is strictly a display layer. All document parsing happens server-side. You provide it an array of image URLs (one per page), and it handles the rest.
Features
- Zero dependencies - no runtime dependencies for the core or Web Component; React is an optional peer dependency for the hooks layer
- Web Component + React hooks - use the self-contained
<document-page-viewer>custom element in any framework, or compose your own UI with the React hooks - Modular architecture - core modules (zoom, image loading, virtualization, reduced motion) are independently importable and useful outside the viewer
- Shadow DOM encapsulation - styles are fully isolated and won't leak into or be affected by your application's CSS
- CSS custom property theming - override a handful of CSS variables to match your app's design system
- Keyboard navigation - full keyboard support for page navigation, zoom, fullscreen, and sidebar toggling
- Thumbnail sidebar - collapsible sidebar with page thumbnails for quick navigation
- Fit modes - fit to width, fit to page, and actual size (100%), with discrete zoom steps from 25% to 400%
- Scroll-center-preserving zoom - zooming in/out keeps the viewport centered on the same content
- Image preloading - adjacent pages are preloaded ahead of time for seamless scrolling
- Page virtualization - pages far from the current viewport are unloaded to conserve memory in large documents
- Pinch-to-zoom - native two-finger zoom on touch devices
- Ctrl+Wheel zoom - standard desktop zoom gesture
- Click-and-drag panning - grab and pan when content overflows the viewport
- Fullscreen support - toggle fullscreen with a button or the
Fkey - Reduced motion support - detects
prefers-reduced-motionat runtime (not just via CSS) and disables smooth scrolling and animations accordingly - Error handling and retry - failed page loads show an error state with a retry button
- Loading skeletons - shimmer placeholders while pages load
- Mobile responsive - adaptive toolbar with collapsible zoom controls on small screens
- Accessible - ARIA roles, labels, live regions, and screen reader announcements throughout
Architecture
core/
types.js # Constants, URL validation, fit-mode checks
zoom.js # clampZoom, nextZoomStep, computeFitZoom, etc.
image-loader.js # ImageLoadController (load, preload, retry, virtualize)
reduced-motion.js # ReducedMotionController (live media query listener)
---
document-page-viewer.js # Web Component (Shadow DOM, toolbar, thumbnails, observers)
---
react/
useZoom.js # Hook wrapping core zoom functions
usePageVirtualization.js # Hook wrapping ImageLoadController
useDocumentViewer.js # Composed hook (zoom + virtualization + keyboard + fullscreen)Each layer is independently useful. useZoom can power any zoomable interface. usePageVirtualization can manage image loading for any scroll-based gallery. The Web Component composes them all into a self-contained viewer.
Install
npm install @joelouf/doc-viewerQuick Start
Plain HTML (Web Component)
<script type="module" src="@joelouf/doc-viewer/document-page-viewer.js"></script>
<document-page-viewer
id="viewer"
show-thumbnails
fit-mode="width"
style="width: 100%; height: 100vh;">
</document-page-viewer>
<script>
document.getElementById('viewer').pages = [
'/pages/page-1.png',
'/pages/page-2.png',
'/pages/page-3.png'
];
</script>React (Hooks)
import { useDocumentViewer } from '@joelouf/doc-viewer/react';
function Viewer({ pages }) {
const viewer = useDocumentViewer({ pages, defaultFitMode: 'width' });
// Build your own UI using:
// viewer.zoom, viewer.currentPage, viewer.handleKeyDown, viewer.zoomIn(), viewer.zoomOut(), viewer.scrollToPage(n), etc.
}Vue / Svelte / Angular
The Web Component works in any framework:
<script type="module">
import '@joelouf/doc-viewer';
</script>
<document-page-viewer show-thumbnails fit-mode="width"></document-page-viewer>Core Only
Import the framework-agnostic modules directly:
import {
computeFitZoom,
clampZoom,
ImageLoadController
} from '@joelouf/doc-viewer/core';
const zoom = computeFitZoom('width', { w: 800, h: 600 });Blob Input (Dynamically Generated Pages)
Pass Blob objects directly as page sources. The viewer manages object URL creation and cleanup internally.
// Example: canvas-rendered pages passed as Blobs.
const blobs = await Promise.all(
canvases.map((canvas) =>
new Promise((resolve) => canvas.toBlob(resolve, 'image/png'))
)
);
document.getElementById('viewer').pages = blobs;// React: mixed URL and Blob sources.
const viewer = useDocumentViewer({
pages: ['/static/cover.png', dynamicBlob, '/static/back.png'],
});API
Web Component Attributes
| Attribute | Property | Type | Default |
|---|---|---|---|
| pages | pages | (string \| Blob)[] | [] |
| default-zoom | defaultZoom | number | 1.0 |
| fit-mode | fitMode | 'width' \| 'page' \| 'actual' | 'width' |
| show-thumbnails | showThumbnails | boolean | true |
| class-name | - | string | - |
The pages attribute accepts a JSON-stringified array of image URLs. The pages property accepts a native JavaScript array of URL strings, Blob objects, or a mix of both.
Read-Only Properties
| Property | Type | Description |
|---|---|---|
| currentPage | number | Currently visible page (1-based) |
| zoom | number | Current zoom level (0.25-4.0) |
| activeFitMode | string \| null | Active fit mode, or null if manually zoomed |
| prefersReducedMotion | boolean | Live reduced-motion preference |
Public Methods
| Method | Description |
|---|---|
| scrollToPage(num) | Scroll to a specific page (1-based) |
| setZoom(level) | Set zoom to a specific level (clamped to 0.25-4.0) |
Events
| Event | detail |
|---|---|
| page-change | { page, previousPage, total } |
| zoom-change | { zoom, previousZoom, percent, fitMode } |
const viewer = document.querySelector('document-page-viewer');
viewer.addEventListener('page-change', (e) => {
console.log(`Page ${e.detail.page} of ${e.detail.total}`);
});
viewer.addEventListener('zoom-change', (e) => {
console.log(`Zoom: ${e.detail.percent}`);
});Keyboard Shortcuts
| Key | Action |
|---|---|
| ↑ / PageUp | Previous page |
| ↓ / PageDown | Next page |
| Home / End | First / last page |
| + / Ctrl+= | Zoom in |
| - / Ctrl+- | Zoom out |
| F | Toggle fullscreen |
| T | Toggle thumbnails |
| Escape | Exit fullscreen |
React Hooks
useDocumentViewer(options?)
The primary hook. Composes zoom, virtualization, keyboard shortcuts, fullscreen, and reduced motion into a single return object.
const viewer = useDocumentViewer({
pages: ['/page-1.png', '/page-2.png'],
// 'width' | 'page' | 'actual'
defaultFitMode: 'width',
defaultZoom: 1.0,
showThumbnails: true,
onPageChange: (detail) => {},
onZoomChange: (detail) => {},
});Returns state (currentPage, zoom, fitMode, isFullscreen, prefersReducedMotion, totalPages), actions (zoomIn, zoomOut, setZoom, applyFitMode, scrollToPage, toggleFullscreen, toggleThumbnails), virtualization methods (loadPage, loadWithNeighbors, retryPage, virtualizePage, getPageState, getNaturalSize), event handlers (handleKeyDown), and refs (viewportRef, hostRef).
useZoom(options?)
Standalone zoom state management. Useful for building any zoomable interface.
const { zoom, fitMode, zoomIn, zoomOut, setZoom, applyFitMode, zoomPercent } = useZoom({
defaultZoom: 1.0,
defaultFitMode: 'width',
onZoomChange: (detail) => {},
});usePageVirtualization({ pages })
Standalone image loading and virtualization. Useful for any scroll-based image gallery.
const {
imageLoader, loadGeneration, getPageState, getNaturalSize,
getFirstLoadedAspect, loadPage, loadWithNeighbors, retryPage, virtualizePage,
} = usePageVirtualization({ pages });Core Modules
All core modules are framework-agnostic, pure functions or imperative controllers with zero DOM side effects (except ImageLoadController which creates <img> elements and ReducedMotionController which listens to a media query).
Zoom Functions
import {
// Clamp and snap to nearest step
clampZoom,
// Next discrete zoom level up
nextZoomStep,
// Next discrete zoom level down
prevZoomStep,
// Calculate zoom for a fit mode + viewport
computeFitZoom,
// Page display width at a zoom level
computePageDisplaySize,
// Scroll position adjustment after zoom change
preserveScrollCenter,
} from '@joelouf/doc-viewer/core';Constants
import {
// ['width', 'page', 'actual']
FIT_MODES,
// [0.25, 0.5, 0.75, 1.0, 1.25, 1.5, 2.0, 3.0, 4.0]
ZOOM_STEPS,
// 8.5 / 11 (US Letter)
DEFAULT_PAGE_ASPECT,
// 816 (96dpi × 8.5 inches)
PAGE_NATURAL_WIDTH,
// 24px between pages
PAGE_GAP,
// 120px thumbnail width
THUMB_WIDTH,
// 164px sidebar width
SIDEBAR_WIDTH,
// 768px
MOBILE_BREAKPOINT,
// 2 pages ahead/behind
PRELOAD_DISTANCE,
// 5 pages before unloading
VIRTUALIZE_DISTANCE,
} from '@joelouf/doc-viewer/core';ImageLoadController
Manages per-image state machines (idle → loading → loaded | error), preloading of adjacent pages, retry logic, and virtualization.
import { ImageLoadController, ImageState } from '@joelouf/doc-viewer/core';
const loader = new ImageLoadController({
urls: ['/page-1.png', '/page-2.png', '/page-3.png'],
onStateChange: (index, state, img) => {
// State is one of: ImageState.IDLE, LOADING, LOADED, ERROR
},
});
// Load page 0 and its neighbors
loader.loadWithNeighbors(0);
// Retry a failed page
loader.retry(2);
// Unload a distant page to free memory
loader.virtualize(5);
// Clean up
loader.destroy();ReducedMotionController
Listens to the prefers-reduced-motion media query and detects runtime changes (e.g., user toggles the OS setting while the viewer is open).
import { ReducedMotionController } from '@joelouf/doc-viewer/core';
const ctrl = new ReducedMotionController((reduced) => {
console.log('Reduced motion:', reduced);
});
// Current state
console.log(ctrl.matches);
// Remove listener
ctrl.destroy();CSS Custom Properties
Style the viewer by setting CSS custom properties on the <document-page-viewer> element or any ancestor:
| Property | Default | Description |
|---|---|---|
| --dpv-bg | #525659 | Viewport background |
| --dpv-toolbar-bg | #2b2d31 | Toolbar background |
| --dpv-toolbar-text | #d4d4d8 | Toolbar text and icon color |
| --dpv-page-shadow | 0 2px 16px rgba(0,0,0,0.35) | Shadow around page frames |
| --dpv-thumbnail-border | #52525b | Thumbnail border color |
| --dpv-accent | #3b82f6 | Accent color (active states, focus rings) |
document-page-viewer {
--dpv-bg: #1a1a2e;
--dpv-toolbar-bg: #16213e;
--dpv-accent: #e94560;
}URL and Source Security
The viewer validates all page sources before use.
URL strings — only the following are accepted:
https://URLs- Relative paths (
/,./,../) http://localhostandhttp://127.*(for local development)
javascript:, data:, and blob: URL strings are rejected. External images are loaded with crossorigin="anonymous".
Blob objects — accepted directly as page sources. The viewer creates object URLs internally via URL.createObjectURL() and revokes them automatically when pages change or the component disconnects. This is the recommended approach for dynamically generated page images (e.g., canvas-rendered previews).
All page content is rendered via <img> elements only - no innerHTML, iframe, embed, or object tags are used.
Browser Support
Chrome 90+, Firefox 90+, Safari 15.4+, Edge 90+, iOS Safari 15.4+, Android Chrome 90+.
License
MIT
