PDF.js Viewer with Thumbnails, Zoom and Rotate — Free JavaScript Snippet
PDF.js Viewer with Thumbnails, Zoom and Rotate · Media · Plain HTML, CSS & JS · Live preview
Copy & paste
Source Code
<div class="pv">
<div class="pv-toolbar" role="toolbar" aria-label="PDF controls">
<label class="pv-open">Open PDF<input type="file" id="pvFile" accept="application/pdf"></label>
<span class="pv-sep"></span>
<button type="button" id="pvPrev" aria-label="Previous page">‹</button>
<label class="pv-page"><input id="pvNum" type="number" min="1" value="1" aria-label="Page number"> / <span id="pvCount">–</span></label>
<button type="button" id="pvNext" aria-label="Next page">›</button>
<span class="pv-sep"></span>
<button type="button" id="pvOut" aria-label="Zoom out">−</button>
<select id="pvZoom" aria-label="Zoom level">
<option value="fit">Fit width</option><option value="0.5">50%</option><option value="0.75">75%</option>
<option value="1">100%</option><option value="1.25">125%</option><option value="1.5">150%</option><option value="2">200%</option>
</select>
<button type="button" id="pvIn" aria-label="Zoom in">+</button>
<button type="button" id="pvRot" aria-label="Rotate 90 degrees">⟳</button>
<span class="pv-name" id="pvName"></span>
</div>
<div class="pv-body">
<nav class="pv-thumbs" id="pvThumbs" aria-label="Page thumbnails"></nav>
<div class="pv-stage" id="pvStage" tabindex="0" aria-label="Page view"><canvas id="pvCanvas"></canvas></div>
</div>
</div>*{box-sizing:border-box;margin:0;padding:0}
body{font-family:system-ui,-apple-system,sans-serif;background:#1f2937;color:#e5e7eb;height:100vh;padding:14px;overflow:hidden}
.pv{height:100%;display:flex;flex-direction:column;max-width:1200px;margin:0 auto;background:#111827;border:1px solid #374151;border-radius:14px;overflow:hidden}
.pv-toolbar{display:flex;align-items:center;gap:6px;padding:8px 10px;background:#0b1220;border-bottom:1px solid #374151;flex-wrap:wrap}
.pv-toolbar button,.pv-toolbar select,.pv-open{background:#1f2937;color:#e5e7eb;border:1px solid #374151;border-radius:7px;height:30px;min-width:30px;padding:0 8px;font:600 13px system-ui;cursor:pointer}
.pv-toolbar button:disabled{opacity:.35;cursor:default}
.pv-open{display:inline-flex;align-items:center;background:#2563eb;border-color:#2563eb}
.pv-open input{position:absolute;width:1px;height:1px;opacity:0}
.pv-open:focus-within,.pv-toolbar :focus-visible{outline:2px solid #60a5fa;outline-offset:1px}
.pv-page{display:flex;align-items:center;gap:5px;font-size:13px}
.pv-page input{width:46px;height:30px;background:#1f2937;color:#e5e7eb;border:1px solid #374151;border-radius:7px;text-align:center;font:600 13px system-ui}
.pv-sep{width:1px;height:20px;background:#374151;margin:0 4px}
.pv-name{margin-left:auto;font-size:12px;color:#9ca3af;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:260px}
.pv-body{flex:1;display:flex;min-height:0}
.pv-thumbs{width:150px;flex:0 0 auto;overflow-y:auto;padding:10px;border-right:1px solid #374151;display:flex;flex-direction:column;gap:10px;scrollbar-width:thin}
@media (max-width:640px){.pv-thumbs{display:none}}
.pv-thumbs button{background:none;border:2px solid transparent;border-radius:6px;padding:3px;cursor:pointer;color:#9ca3af;font:600 11px system-ui}
.pv-thumbs button canvas{width:100%;display:block;background:#fff;border-radius:2px}
.pv-thumbs button[aria-current="page"]{border-color:#60a5fa;color:#e5e7eb}
.pv-thumbs button:focus-visible{outline:2px solid #60a5fa}
.pv-stage{flex:1;overflow:auto;padding:18px;display:grid;place-items:start center;background:#374151;scrollbar-width:thin}
.pv-stage:focus-visible{outline:2px solid #60a5fa;outline-offset:-2px}
#pvCanvas{background:#fff;box-shadow:0 10px 30px rgba(0,0,0,.45)}pdfjsLib.GlobalWorkerOptions.workerSrc = 'https://cdn.jsdelivr.net/npm/[email protected]/build/pdf.worker.min.js';
var pdf = null, pageNum = 1, zoom = 'fit', rotation = 0, renderTask = null;
var currentScale = 1; // the scale actually used by the last render (fit width included)
var canvas = document.getElementById('pvCanvas');
var stage = document.getElementById('pvStage');
var thumbs = document.getElementById('pvThumbs');
// A sample document built with jsPDF so the viewer opens with something to
// show and no network request for a file.
function sampleBytes() {
var doc = new jspdf.jsPDF({ unit: 'pt', format: 'a4' });
var colors = [[37, 99, 235], [220, 38, 38], [5, 150, 105], [217, 119, 6], [124, 58, 237]];
var titles = ['Field Guide to Web Fonts', 'Chapter 1: Anatomy', 'Chapter 2: Loading', 'Chapter 3: Performance', 'Index'];
titles.forEach(function (title, i) {
if (i) doc.addPage(i === 3 ? 'a4' : 'a4', i === 3 ? 'landscape' : 'portrait');
var W = doc.internal.pageSize.getWidth(), H = doc.internal.pageSize.getHeight();
doc.setFillColor.apply(doc, colors[i]);
doc.rect(0, 0, W, 90, 'F');
doc.setTextColor(255, 255, 255);
doc.setFont('helvetica', 'bold'); doc.setFontSize(24);
doc.text(title, 40, 58);
doc.setTextColor(55, 65, 81); doc.setFont('helvetica', 'normal'); doc.setFontSize(11);
var para = 'Typography on the web is a negotiation between design intent and the realities of network, rendering and device. ';
doc.text(doc.splitTextToSize(para.repeat(9), W - 80), 40, 130);
doc.setDrawColor.apply(doc, colors[i]); doc.setLineWidth(2);
doc.roundedRect(40, H - 220, W - 80, 140, 8, 8);
doc.setFontSize(13); doc.setTextColor.apply(doc, colors[i]);
doc.text(i === 3 ? 'This page is landscape' : 'Figure ' + (i + 1), 60, H - 185);
doc.setFontSize(9); doc.setTextColor(156, 163, 175);
doc.text(String(i + 1), W / 2, H - 28, { align: 'center' });
});
return doc.output('arraybuffer');
}
async function open(data, name) {
if (pdf) pdf.destroy();
pdf = await pdfjsLib.getDocument({ data: data }).promise;
pageNum = 1; rotation = 0;
document.getElementById('pvCount').textContent = pdf.numPages;
document.getElementById('pvNum').max = pdf.numPages;
document.getElementById('pvName').textContent = name;
buildThumbs();
render();
}
async function buildThumbs() {
thumbs.innerHTML = '';
var doc = pdf;
for (var n = 1; n <= doc.numPages; n++) {
var page = await doc.getPage(n);
if (doc !== pdf) return; // a different file was opened meanwhile
var vp = page.getViewport({ scale: 110 / page.getViewport({ scale: 1 }).width });
var c = document.createElement('canvas');
c.width = vp.width; c.height = vp.height;
var b = document.createElement('button');
b.type = 'button';
b.setAttribute('aria-label', 'Go to page ' + n);
b.dataset.n = n;
b.appendChild(c);
b.insertAdjacentHTML('beforeend', '<div>' + n + '</div>');
thumbs.appendChild(b);
await page.render({ canvasContext: c.getContext('2d'), viewport: vp }).promise;
}
markThumb();
}
function markThumb() {
thumbs.querySelectorAll('button').forEach(function (b) {
if (Number(b.dataset.n) === pageNum) { b.setAttribute('aria-current', 'page'); b.scrollIntoView({ block: 'nearest' }); }
else b.removeAttribute('aria-current');
});
}
async function render() {
if (!pdf) return;
var page = await pdf.getPage(pageNum);
// "Fit width" depends on rotation: a rotated page is as wide as it was tall.
var base = page.getViewport({ scale: 1, rotation: rotation });
var scale = zoom === 'fit' ? (stage.clientWidth - 36) / base.width : Number(zoom);
currentScale = scale;
var dpr = window.devicePixelRatio || 1;
var vp = page.getViewport({ scale: scale * dpr, rotation: rotation });
// Cancel a render still in progress before starting another on the same
// canvas; two renders drawing at once produce a garbled page.
if (renderTask) { renderTask.cancel(); try { await renderTask.promise; } catch (e) {} }
canvas.width = vp.width;
canvas.height = vp.height;
canvas.style.width = (vp.width / dpr) + 'px';
canvas.style.height = (vp.height / dpr) + 'px';
renderTask = page.render({ canvasContext: canvas.getContext('2d'), viewport: vp });
try { await renderTask.promise; } catch (e) { if (e.name !== 'RenderingCancelledException') throw e; }
renderTask = null;
document.getElementById('pvNum').value = pageNum;
document.getElementById('pvPrev').disabled = pageNum <= 1;
document.getElementById('pvNext').disabled = pageNum >= pdf.numPages;
markThumb();
}
function go(n) { if (!pdf) return; pageNum = Math.min(pdf.numPages, Math.max(1, n)); render(); }
var LEVELS = [0.5, 0.75, 1, 1.25, 1.5, 2];
function stepZoom(dir) {
// From "fit width", step relative to the scale fit actually produced.
var current = currentScale;
var next = dir > 0 ? LEVELS.find(function (l) { return l > current + 0.01; }) : LEVELS.slice().reverse().find(function (l) { return l < current - 0.01; });
if (next === undefined) return;
zoom = String(next);
document.getElementById('pvZoom').value = zoom;
render();
}
document.getElementById('pvPrev').addEventListener('click', function () { go(pageNum - 1); });
document.getElementById('pvNext').addEventListener('click', function () { go(pageNum + 1); });
document.getElementById('pvNum').addEventListener('change', function (e) { go(Number(e.target.value) || 1); });
document.getElementById('pvZoom').addEventListener('change', function (e) { zoom = e.target.value; render(); });
document.getElementById('pvIn').addEventListener('click', function () { stepZoom(1); });
document.getElementById('pvOut').addEventListener('click', function () { stepZoom(-1); });
document.getElementById('pvRot').addEventListener('click', function () { rotation = (rotation + 90) % 360; render(); });
thumbs.addEventListener('click', function (e) { var b = e.target.closest('button'); if (b) go(Number(b.dataset.n)); });
stage.addEventListener('keydown', function (e) {
if (e.key === 'ArrowRight' || e.key === 'PageDown') { e.preventDefault(); go(pageNum + 1); }
if (e.key === 'ArrowLeft' || e.key === 'PageUp') { e.preventDefault(); go(pageNum - 1); }
if (e.key === '+' || e.key === '=') stepZoom(1);
if (e.key === '-') stepZoom(-1);
});
var rt;
window.addEventListener('resize', function () { if (zoom === 'fit') { clearTimeout(rt); rt = setTimeout(render, 120); } });
document.getElementById('pvFile').addEventListener('change', function (e) {
var f = e.target.files[0];
if (!f) return;
f.arrayBuffer().then(function (buf) { return open(new Uint8Array(buf), f.name); })
.catch(function (err) { document.getElementById('pvName').textContent = 'Could not open ' + f.name + ': ' + err.message; });
});
open(new Uint8Array(sampleBytes()), 'field-guide-sample.pdf');Extend with AI
Take This Snippet Further With AI
The AI prompt for this one is for Patreon supporters ($3 a month, 7 days free), along with a site with no ads. Supporters enter the passcode once and it is remembered in this browser.
What's included
Features
About this UI Snippet
Build Your Own PDF Viewer With PDF.js — Thumbnails, Zoom and Rotation
Browsers can show PDFs natively, but an embedded native viewer can't be styled, controlled or integrated with the rest of your UI. PDF.js — the engine behind Firefox's built-in viewer — gives you the rendering and leaves the interface to you. This snippet builds a compact viewer with the controls people expect.
Load, get a page, render to canvas
pdfjsLib.getDocument({ data }) parses the file and resolves to a document. getPage(n) returns a page, getViewport({ scale, rotation }) computes its size and transform, and page.render({ canvasContext, viewport }) draws it onto a canvas. Everything else in a viewer is state around those calls: current page, zoom level and rotation.
Sharp on high-DPI screens
The canvas is rendered at scale × devicePixelRatio and then shrunk back with CSS width and height, so text stays crisp on Retina and 4K displays instead of looking blurry.
Fit width, including rotated pages
Fit width divides the available stage width by the page's width at scale 1 — measured *with the current rotation*, because a rotated portrait page is as wide as it was tall. The sample includes a landscape page to show mixed orientations.
Cancel before re-rendering
Clicking zoom twice quickly would start two renders on the same canvas and produce a corrupted page. The snippet keeps the current renderTask, cancels it, waits for it to settle, then starts the new render; the RenderingCancelledException from a cancelled task is expected and ignored.
Thumbnails
Every page is also rendered into a small canvas in the sidebar. Clicking one jumps to it, and the current page is marked with aria-current="page" and scrolled into view.
Private file opening
Opening a local file reads it with the File API; nothing is uploaded. A generated five-page sample is shown on load.
Step by step
How to Use
- 1Browse the sampleA five-page document opens with thumbnails on the left.
- 2NavigateUse ‹ ›, type a page number, click a thumbnail, or arrow keys on the page.
- 3ZoomFit width, preset levels, or + and − (buttons or keys).
- 4Rotate⟳ turns the page 90° at a time; fit width adjusts.
- 5Open your own PDFIt's read locally and never uploaded.
Real-world uses
Common Use Cases
Got questions?
Frequently Asked Questions
Load the document with pdfjsLib.getDocument({ url }) or ({ data }), get a page with getPage(n), create a viewport with getViewport({ scale }), size a canvas to the viewport and call page.render({ canvasContext, viewport }).
The canvas is being drawn at CSS pixel size on a high-DPI screen. Render at scale × window.devicePixelRatio and set the canvas's CSS width and height to the unscaled size.
Pass rotation (0, 90, 180 or 270) to getViewport. Remember that width and height swap at 90 and 270 degrees when calculating fit-to-width.
Two renders are drawing into the same canvas. Keep the render task, call cancel() on it before starting a new render, and ignore the RenderingCancelledException it rejects with.
PDF.js 3.x ships a classic pdf.min.js that defines window.pdfjsLib. Version 4 and later are ES modules, which you load with a module script or a bundler.