How to build a thumbnail sidebar with PDF.js PDFThumbnailViewer
Table of contents
PDFThumbnailViewer for rendering page thumbnails in a sidebar. This tutorial covers both the built-in approach and building custom thumbnails with lazy loading using the lower-level `page.render()` API.
- Use PDF.js’s built-in
PDFThumbnailViewer(frompdfjs-dist/web/pdf_viewer.mjs) for a sidebar with rendering, caching, and click navigation handled for you — share itsPDFRenderingQueuewith the main viewer. - For a custom UI, render pages to small canvases via
page.render()at a lowscale(0.15–0.25). Then convert withcanvas.toDataURL(). - For large documents, lazy-load thumbnails with
IntersectionObserverand callrenderTask.cancel()on fast scroll to avoid wasted work. - Nutrient Web SDK ships a thumbnail sidebar via
SidebarMode.THUMBNAILS— one line of configuration replaces the entire pipeline above.
PDF.js includes a built-in PDFThumbnailViewer for rendering page thumbnails in a sidebar. You can also build thumbnails manually using the lower-level page.render() API for more control.
Option 1: Built-in PDFThumbnailViewer
The viewer layer includes a thumbnail viewer that handles rendering, caching, and scroll synchronization. It needs a shared PDFRenderingQueue so thumbnail renders coordinate with main-viewer renders:
const pdfjs = await import("pdfjs-dist/web/pdf_viewer.mjs");
const thumbnailContainer = document.getElementById("thumbnail-container");
const thumbnailViewer = new pdfjs.PDFThumbnailViewer({ container: thumbnailContainer, eventBus, linkService, renderingQueue,});
// After document loads.thumbnailViewer.setDocument(pdfDocument);HTML structure
PDFThumbnailViewer renders into a container element you provide:
<div id="thumbnail-container" class="thumbnailContainer"> <!-- `PDFThumbnailViewer` populates this --></div>CSS
Style the container and each thumbnail so the sidebar scrolls independently and highlights the selected page:
.thumbnailContainer { width: 200px; height: 100%; overflow-y: auto; background: #2a2a2e; padding: 8px;}
.thumbnail { margin: 4px auto; cursor: pointer; border: 2px solid transparent; border-radius: 4px;}
.thumbnail.selected { border-color: #4A90D9;}
.thumbnailImage { box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);}Synchronizing with the main viewer
The thumbnail viewer automatically highlights the current page when you listen to page changes:
eventBus.on("pagechanging", (evt) => { thumbnailViewer.scrollThumbnailIntoView(evt.pageNumber);});Clicking a thumbnail navigates to that page (handled automatically via linkService).
Option 2: Custom thumbnails with page.render()
For more control over thumbnail appearance, render pages to small canvases:
async function renderThumbnail(pdfDocument, pageNumber, scale = 0.2) { const page = await pdfDocument.getPage(pageNumber); const viewport = page.getViewport({ scale });
const canvas = document.createElement("canvas"); canvas.width = viewport.width; canvas.height = viewport.height;
const context = canvas.getContext("2d"); await page.render({ canvasContext: context, viewport, }).promise;
return canvas;}React thumbnail grid
Wire renderThumbnail() into a component that renders every page as a clickable thumbnail and tracks the current page:
function ThumbnailSidebar({ pdfDocument, viewer }) { const [thumbnails, setThumbnails] = useState([]); const [currentPage, setCurrentPage] = useState(1);
useEffect(() => { async function generateThumbnails() { const thumbs = []; for (let i = 1; i <= pdfDocument.numPages; i++) { const canvas = await renderThumbnail(pdfDocument, i, 0.2); thumbs.push({ page: i, dataUrl: canvas.toDataURL() }); } setThumbnails(thumbs); } generateThumbnails(); }, [pdfDocument]);
return ( <div className="thumbnail-sidebar"> {thumbnails.map(({ page, dataUrl }) => ( <button key={page} type="button" className={`thumbnail ${page === currentPage ? "selected" : ""}`} onClick={() => { viewer.currentPageNumber = page; }} > <img src={dataUrl} alt={`Page ${page}`} /> <span>{page}</span> </button> ))} </div> );}Lazy loading thumbnails
For large documents, generate thumbnails on demand using an IntersectionObserver:
function LazyThumbnail({ pdfDocument, pageNumber, onClick, isSelected }) { const [dataUrl, setDataUrl] = useState(null); const ref = useRef(null); const rendered = useRef(false);
useEffect(() => { if (rendered.current) return; const observer = new IntersectionObserver( ([entry]) => { if (entry.isIntersecting) { rendered.current = true; renderThumbnail(pdfDocument, pageNumber, 0.2).then((canvas) => { setDataUrl(canvas.toDataURL()); }); observer.disconnect(); } }, { threshold: 0.1 }, );
if (ref.current) observer.observe(ref.current); return () => observer.disconnect(); }, [pdfDocument, pageNumber]);
return ( <button ref={ref} type="button" className={`thumbnail ${isSelected ? "selected" : ""}`} onClick={onClick} style={{ minHeight: 150 }} > {dataUrl ? ( <img src={dataUrl} alt={`Page ${pageNumber}`} /> ) : ( <div className="thumbnail-placeholder" /> )} <span>{pageNumber}</span> </button> );}Canceling renders
For fast scrolling, cancel pending thumbnail renders. The currentRenderTask below is module-scoped for clarity — scope it per sidebar instance (e.g. a ref) if you mount more than one viewer:
let currentRenderTask = null;
async function renderThumbnailCancellable(page, scale) { const viewport = page.getViewport({ scale }); const canvas = document.createElement("canvas"); canvas.width = viewport.width; canvas.height = viewport.height;
// Cancel previous render if still running. if (currentRenderTask) { currentRenderTask.cancel(); }
const renderTask = page.render({ canvasContext: canvas.getContext("2d"), viewport, }); currentRenderTask = renderTask;
try { await renderTask.promise; return canvas; } catch (error) { if (error.name === "RenderingCancelledException") { return null; // Canceled, ignore. } throw error; }}Key points
PDFThumbnailVieweris the easiest option — it handles rendering, caching, and click navigation.- For a custom UI, use
page.render()with a small viewportscale(0.15–0.25). - Use
canvas.toDataURL()to convert thumbnails to displayable images. - Lazy-load with
IntersectionObserverfor large documents. renderTask.cancel()prevents wasted work when scrolling fast through thumbnails.- The
PDFThumbnailViewersynchronizes with the main viewer viaEventBusautomatically.
How Nutrient Web SDK handles this
Instead of setting up PDFThumbnailViewer, writing custom canvas rendering, and managing IntersectionObserver lazy loading, Nutrient provides a built-in thumbnail sidebar with a single line of code:
// Enable the built-in thumbnail sidebar — one line.instance.setViewState((v) => v.set("sidebarMode", NutrientViewer.SidebarMode.THUMBNAILS),);There’s no PDFThumbnailViewer setup, no custom canvas rendering, no IntersectionObserver lazy loading, and no render cancellation logic. Nutrient’s built-in thumbnail sidebar handles rendering, lazy loading, scroll synchronization, and page navigation automatically. For page reordering, rotation, duplication, and deletion, switch to Document Editor mode (InteractionMode.DOCUMENT_EDITOR).
Learn more about Nutrient Web SDK | Migration guide | Contact Sales
FAQ
Yes — the viewer layer (pdfjs-dist/web/pdf_viewer.mjs) ships PDFThumbnailViewer. Its constructor needs container, eventBus, linkService, and renderingQueue. Share that renderingQueue with the main PDFViewer so thumbnail and page renders coordinate.
PDFThumbnailViewer?Reach for page.render() when you need a layout the built-in component doesn’t support (grid view, embedded inside a custom React tree, virtualized list, etc.) or when you want different styling than the public CSS classes allow. Otherwise, PDFThumbnailViewer is the lower-effort path.
Wrap each thumbnail in an IntersectionObserver and only call page.render() when the placeholder scrolls into view. Track a rendered ref so the observer doesn’t fire repeatedly, and disconnect after the first intersection.
page.render() returns a RenderTask with a cancel() method. Store the latest task, call cancel() before starting a new one, and catch RenderingCancelledException in the await so a canceled render doesn’t surface as an error.
No — PDFThumbnailViewer is navigation-only. Page reordering, rotation, insertion, and deletion aren’t part of the open source viewer. That requires a custom UI plus a library like pdf-lib to persist changes, or an SDK such as Nutrient Web SDK, whose Document Editor mode provides those operations via toolbar buttons.
0.15–0.25 is the usual range. Lower scales render faster and use less memory but get blurry on screens with a high device pixel ratio (DPR). If you want crisp thumbnails on Retina displays, multiply your chosen scale by window.devicePixelRatio and downscale the rendered canvas with CSS.