| name | pdfjs-impl-custom-viewer |
| description | Use when building a complete PDF viewer from scratch with page navigation, zoom, search, and print functionality. Prevents the critical mistake of rendering all pages at once instead of using lazy loading with IntersectionObserver. Covers page navigation, zoom controls, scroll-based lazy loading, virtual scrolling, thumbnail generation, text search with highlighting, and print support. Keywords: PDF viewer, lazy loading, IntersectionObserver, zoom, search, print, thumbnails, build PDF viewer, render PDF in browser, view PDF online, page navigation, custom reader.
|
| license | MIT |
| compatibility | Designed for Claude Code. Requires pdfjs-dist 5.x. |
| metadata | {"author":"OpenAEC-Foundation","version":"1.0"} |
pdfjs-impl-custom-viewer
Quick Reference
Viewer Architecture
| Component | Purpose | Key Pattern |
|---|
| Page container | Scrollable wrapper holding all page slots | Single <div> with overflow-y: auto |
| Page slots | Placeholder divs sized to page dimensions | Created for ALL pages, rendered lazily |
| IntersectionObserver | Triggers render/cleanup as pages enter/leave viewport | rootMargin: "200px" for pre-rendering |
| Page renderer | Renders canvas + text + annotation layers | Cancel-then-render with DPI handling |
| Navigation bar | Previous/next, go-to-page, page count | Updates on scroll position change |
| Zoom controls | Scale manipulation with re-render | Cancel all visible renders, re-render at new scale |
| Search engine | Text extraction + match highlighting | page.getTextContent() across all pages |
| Thumbnail panel | Small-scale page previews | Render at scale 0.2-0.3, cache as ImageBitmap |
| Print handler | High-resolution render for printing | intent: "print", CSS @media print |
Critical Warnings
NEVER render all pages at once -- ALWAYS use IntersectionObserver to render only visible pages plus a buffer. A 500-page PDF at scale 1.5 on a Retina display would consume 18 GB of memory.
ALWAYS clean up pages that scroll out of view -- remove canvas elements and nullify references to allow garbage collection. Without cleanup, memory grows linearly as the user scrolls.
ALWAYS cancel in-progress RenderTasks before starting new renders -- zoom changes, page navigation, and cleanup all require cancellation first.
NEVER call getPage() for all pages during initialization -- get page dimensions from the first page or use doc.getPage() lazily. Calling getPage() for 500 pages blocks the UI.
ALWAYS debounce scroll-based current page detection -- the scroll event fires at 60fps; reading getBoundingClientRect() for every page on every frame causes layout thrashing.
ALWAYS set will-change: transform on page containers to promote them to compositor layers and prevent full-page repaints during scrolling.
Essential Patterns
Viewer HTML Structure
<div id="pdf-viewer">
<div id="toolbar">
<button id="prev-page">Previous</button>
<input id="page-input" type="number" min="1" /> / <span id="page-count"></span>
<button id="next-page">Next</button>
<button id="zoom-in">+</button>
<button id="zoom-out">-</button>
<select id="zoom-select">
<option value="fit-width">Fit Width</option>
<option value="fit-page">Fit Page</option>
< =>50%
100%
150%
200%
Prev Match
Next Match
Print
Viewer Initialization
import { getDocument, GlobalWorkerOptions } from "pdfjs-dist";
import type { PDFDocumentProxy } from "pdfjs-dist";
GlobalWorkerOptions.workerSrc = new URL(
"pdfjs-dist/build/pdf.worker.min.mjs",
import.meta.url
).toString();
interface ViewerState {
doc: PDFDocumentProxy;
currentPage: number;
scale: number;
numPages: number;
pageHeights: number[];
}
async function initViewer(url: string): Promise<ViewerState> {
const doc = await getDocument({ url }).promise;
const numPages = doc.numPages;
const firstPage = await doc.getPage();
container = .()!;
baseViewport = firstPage.({ : });
scale = container. / baseViewport.;
: [] = [];
( i = ; i <= numPages; i++) {
page = doc.(i);
viewport = page.({ scale });
pageHeights.(viewport.);
slot = .();
slot. = ;
slot.. = (i);
slot.. = ;
slot.. = ;
slot.. = ;
slot.. = ;
slot.. = ;
slot.. = ;
container.(slot);
}
: = { doc, : , scale, numPages, pageHeights };
(state, container);
(state, container);
(state, container);
.()!. = (numPages);
state;
}
Lazy Rendering with IntersectionObserver
import type { PDFDocumentProxy, RenderTask } from "pdfjs-dist";
const activeRenders = new Map<number, RenderTask>();
const renderedPages = new Set<number>();
function setupLazyRendering(state: ViewerState, container: HTMLElement): void {
const observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
const pageNum = Number((entry.target as HTMLElement).dataset.pageNumber);
if (entry.isIntersecting) {
if (!renderedPages.has(pageNum)) {
renderPage(state, entry.target as HTMLDivElement, pageNum);
}
} else {
cleanupPage(pageNum, entry.target );
}
}
},
{
: container,
: ,
}
);
container.().( observer.(el));
}
Current Page Detection from Scroll
function setupScrollPageDetection(
state: ViewerState,
container: HTMLElement
): void {
let debounceTimer: ReturnType<typeof setTimeout>;
container.addEventListener("scroll", () => {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => {
const containerRect = container.getBoundingClientRect();
const centerY = containerRect.top + containerRect.height / 2;
const slots = container.querySelectorAll(".page-slot");
for (const slot of slots) {
const rect = slot.getBoundingClientRect();
if (rect.top <= centerY && rect.bottom >= centerY) {
state.currentPage = Number((slot as HTMLElement).dataset.pageNumber);
updatePageIndicator(state);
break;
}
}
}, 50);
});
}
function updatePageIndicator(): {
input = .() ;
input. = (state.);
}
Common Operations
Page Navigation
function goToPage(state: ViewerState, container: HTMLElement, pageNum: number): void {
const clamped = Math.max(1, Math.min(pageNum, state.numPages));
const slot = container.querySelector(`[data-page-number="${clamped}"]`);
if (slot) {
slot.scrollIntoView({ behavior: "smooth", block: "start" });
state.currentPage = clamped;
updatePageIndicator(state);
}
}
function setupNavigation(state: ViewerState, container: HTMLElement): void {
document.getElementById("prev-page")!.addEventListener("click", () => {
goToPage(state, container, state.currentPage - 1);
});
document.getElementById("next-page")!.addEventListener("click", {
(state, container, state. + );
});
(.() )
.(, {
(state, container, ((e. ).));
});
(state, container);
}
Zoom Controls
function setupZoomControls(state: ViewerState, container: HTMLElement): void {
document.getElementById("zoom-in")!.addEventListener("click", () => {
setScale(state, container, state.scale * 1.25);
});
document.getElementById("zoom-out")!.addEventListener("click", () => {
setScale(state, container, state.scale / 1.25);
});
document.getElementById("zoom-select")!.addEventListener("change", (e) => {
const value = (e.target as HTMLSelectElement).value;
if (value === "fit-width") {
fitToWidth(state, container);
} else if (value === "fit-page") {
fitToPage(state, container);
} else {
setScale(state, container, Number(value));
}
});
}
function (): {
state. = .(, .(, newScale));
slots = container.();
slots.( (slot, index) => {
page = state..(index + );
viewport = page.({ : state. });
(slot ).. = ;
(slot ).. = ;
(index + , slot );
renderedPages.(index + );
});
}
(): {
state..().( {
baseViewport = page.({ : });
newScale = container. / baseViewport.;
(state, container, newScale);
});
}
(): {
state..().( {
baseViewport = page.({ : });
scaleW = container. / baseViewport.;
scaleH = container. / baseViewport.;
(state, container, .(scaleW, scaleH));
});
}
Memory Cleanup
function cleanupPage(pageNum: number, slot: HTMLDivElement): void {
const task = activeRenders.get(pageNum);
if (task) {
task.cancel();
activeRenders.delete(pageNum);
}
const canvas = slot.querySelector("canvas");
if (canvas) {
const ctx = canvas.getContext("2d");
if (ctx) {
ctx.clearRect(0, 0, canvas.width, canvas.height);
}
canvas.width = 0;
canvas.height = 0;
canvas.remove();
}
slot.querySelectorAll(".textLayer, .annotationLayer").forEach((el) => el.remove());
renderedPages.delete(pageNum);
}
Decision Tree: Viewer Feature Selection
Building a custom PDF viewer?
├── Need scrollable multi-page view?
│ ├── YES → Create page slots for ALL pages, use IntersectionObserver
│ │ NEVER render all pages at once
│ └── NO (single page) → Simple prev/next with single canvas
│
├── Need zoom?
│ ├── YES → Resize all slots, clear rendered pages, let observer re-render
│ └── NO → Use fit-to-width as fixed scale
│
├── Need search?
│ ├── YES → Extract text from ALL pages (lazy), highlight matches
│ └── NO → Skip text layer if not needed for selection either
│
├── Need thumbnails?
│ ├── YES → Render at scale 0.2-0.3, cache results, lazy-load
│ └── NO → Skip sidebar
│
├── Need print?
│ ├── YES → Hidden print container, render at 300 DPI, CSS @media print
│ └── NO → Skip print button
│
└── Memory-constrained environment?
├── YES → Aggressive cleanup (remove pages 1 screen away from viewport)
└── NO → Keep buffer of 2-3 pages above/below (rootMargin: "600px")
Required CSS
@import "pdfjs-dist/web/pdf_viewer.css";
#page-container {
overflow-y: auto;
height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
background: #808080;
}
.page-slot {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
background: white;
}
.page-slot canvas {
position: absolute;
top: 0;
left: 0;
}
.page-slot .textLayer {
position: absolute;
top: 0;
left: 0;
z-index: 1;
}
.page-slot .annotationLayer {
position: absolute;
top: 0;
left: 0;
z-index: 2;
}
@media print {
#toolbar, #sidebar { display: none; }
#page-container { : visible; : auto; }
{ : page; : none; : ; }
}
Reference Links
Official Sources