ScreenshotNeo

BlogHTML to image & PDF

How to Build an HTML Template for a PDF Viewer

Build a responsive PDF viewer page with PDF.js, including setup, controls, loading states, accessibility checks, and deployment fixes.

By the ScreenshotNeo team1 October 202610 min read

This guide shows how to place an existing PDF inside an HTML page. It does not generate a PDF from HTML. For a custom layout and behavior, use Mozilla PDF.js. For a ready-made interface with common reader controls, use a packaged viewer and customize its shell.

1. Choose your PDF viewer approach

Approach Use it when Trade-off
Custom PDF.js integration You control the page layout, controls, branding, loading states, and application logic. You must build and maintain the controls your users need.
Packaged PDF.js viewer You need a working toolbar, thumbnails, search, zoom, and navigation quickly. You must keep the viewer assets together and adapt its styling and behavior.
Commercial packaged viewer You need features such as annotations, forms, or collaboration and accept the vendor’s licensing terms. Licensing, iframe communication, and product limits require review.

PDF.js has three layers: the core parser, the display API, and the viewer UI. Mozilla describes the viewer as a starting point for building your own interface and asks sites that embed it to reskin or build upon it. See the PDF.js getting-started documentation.

2. Install a version-pinned PDF.js distribution

The current getting-started documentation lists version 6.3.289 for its modern and older-browser prebuilt builds. Treat that as a point-in-time version and check the documentation before upgrading.

Download the prebuilt distribution and preserve both directories:

  • build/ contains pdf.mjs and pdf.worker.mjs.
  • web/ contains viewer CSS, JavaScript, locale files, images, and related assets.

The worker file must remain available at the URL configured by your application. Do not copy only the main module and omit its worker or companion assets.

pdf-viewer/
├── index.html
├── app.js
└── pdfjs/
    ├── build/
    │   ├── pdf.mjs
    │   └── pdf.worker.mjs
    └── web/
        ├── viewer.css
        ├── viewer.mjs
        ├── locale/
        └── images/

During development, serve the directory over HTTP. PDF.js workers are not enabled for file:// URLs. A simple option is any local static server; PDF.js source documentation also lists npx gulp server for a source checkout.

3. Create the HTML shell

The shell needs document metadata, viewer styles, module scripts, and a container with a deliberate height. The following template is a complete starting point for a custom viewer.

<!doctype html>
<html lang="en" dir="ltr">
  <head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>PDF viewer</title>
    <link rel="stylesheet" href="./pdfjs/web/viewer.css">
    <style>
      :root { color-scheme: light dark; }
      * { box-sizing: border-box; }
      body {
        margin: 0;
        font-family: system-ui, sans-serif;
        background: #202124;
        color: #fff;
      }
      .toolbar {
        display: flex;
        gap: .5rem;
        align-items: center;
        padding: .75rem;
        background: #111;
      }
      .toolbar button, .toolbar input {
        min-height: 2.25rem;
      }
      #viewer {
        height: calc(100vh - 4rem);
        overflow: auto;
        padding: 1rem;
      }
      #pageContainer {
        position: relative;
        width: max-content;
        margin: 0 auto;
      }
      .page {
        position: relative;
        margin: 0 auto 1rem;
        background: #fff;
        box-shadow: 0 2px 12px rgb(0 0 0 / .35);
      }
      .page canvas { display: block; }
      #status { padding: .5rem .75rem; min-height: 2rem; }
      @media (max-width: 600px) {
        .toolbar { flex-wrap: wrap; }
        #viewer { padding: .5rem; }
      }
    </style>
  </head>
  <body>
    <div class="toolbar" role="toolbar" aria-label="PDF controls">
      <button id="previous" type="button">Previous</button>
      <span aria-live="polite">Page <span id="pageNumber">1</span> of <span id="pageCount">—</span></span>
      <button id="next" type="button">Next</button>
      <label>Zoom
        <select id="zoom">
          <option value="1">100%</option>
          <option value="1.25">125%</option>
          <option value="1.5">150%</option>
          <option value="2">200%</option>
        </select>
      </label>
    </div>
    <div id="status" role="status">Loading PDF…</div>
    <main id="viewer" aria-label="PDF document">
      <div id="pageContainer" class="pdfViewer singlePageView"></div>
    </main>
    <script type="module" src="./app.js"></script>
  </body>
</html>

The pdfViewer singlePageView classes follow the structural pattern used by PDF.js examples. A fixed or calculated viewer height is essential: without one, an overflowing document can collapse into an apparently blank area.

4. Load and render a document with PDF.js

Put this module in app.js. Change PDF_URL to a same-origin PDF or to a URL whose server explicitly permits your page’s origin. The example renders one page at a time and keeps a small set of controls.

import * as pdfjsLib from './pdfjs/build/pdf.mjs';

pdfjsLib.GlobalWorkerOptions.workerSrc = './pdfjs/build/pdf.worker.mjs';

const PDF_URL = './documents/guide.pdf';
const pageContainer = document.querySelector('#pageContainer');
const status = document.querySelector('#status');
const pageNumber = document.querySelector('#pageNumber');
const pageCount = document.querySelector('#pageCount');
const previous = document.querySelector('#previous');
const next = document.querySelector('#next');
const zoom = document.querySelector('#zoom');

let pdfDocument;
let currentPage = 1;
let scale = Number(zoom.value);

function setStatus(message) {
  status.textContent = message;
}

function updateButtons() {
  previous.disabled = currentPage <= 1;
  next.disabled = !pdfDocument || currentPage >= pdfDocument.numPages;
  pageNumber.textContent = String(currentPage);
}

async function renderPage(pageNumberToRender) {
  const page = await pdfDocument.getPage(pageNumberToRender);
  const viewport = page.getViewport({ scale });
  const wrapper = document.createElement('section');
  wrapper.className = 'page';
  wrapper.setAttribute('aria-label', `Page ${pageNumberToRender}`);
  wrapper.style.width = `${viewport.width}px`;
  wrapper.style.height = `${viewport.height}px`;

  const canvas = document.createElement('canvas');
  const context = canvas.getContext('2d', { alpha: false });
  canvas.width = Math.ceil(viewport.width);
  canvas.height = Math.ceil(viewport.height);
  canvas.setAttribute('aria-label', `Page ${pageNumberToRender}`);
  wrapper.append(canvas);
  pageContainer.replaceChildren(wrapper);

  await page.render({ canvasContext: context, viewport }).promise;
}

async function showPage(nextPage) {
  if (!pdfDocument) return;
  currentPage = Math.min(Math.max(nextPage, 1), pdfDocument.numPages);
  setStatus(`Rendering page ${currentPage}…`);
  updateButtons();
  try {
    await renderPage(currentPage);
    setStatus(`Page ${currentPage} of ${pdfDocument.numPages}`);
  } catch (error) {
    console.error(error);
    setStatus('This page could not be rendered. Try again or use another PDF viewer.');
  }
}

previous.addEventListener('click', () => showPage(currentPage - 1));
next.addEventListener('click', () => showPage(currentPage + 1));
zoom.addEventListener('change', () => {
  scale = Number(zoom.value);
  showPage(currentPage);
});

document.addEventListener('keydown', (event) => {
  if (event.key === 'ArrowLeft') showPage(currentPage - 1);
  if (event.key === 'ArrowRight') showPage(currentPage + 1);
});

try {
  pdfDocument = await pdfjsLib.getDocument({ url: PDF_URL }).promise;
  pageCount.textContent = String(pdfDocument.numPages);
  await showPage(1);
} catch (error) {
  console.error(error);
  setStatus('The PDF could not be loaded. Check the URL, server response, and permissions.');
  previous.disabled = true;
  next.disabled = true;
}

5. Handle URLs, data, and cross-origin files

Same-origin PDF

A relative URL such as ./documents/guide.pdf is the simplest deployment. Ensure the server returns the PDF content and does not redirect to an HTML login page.

Authenticated or generated data

If your application already has the bytes, pass an ArrayBuffer or typed array to getDocument. Keep credentials out of a public URL.

const response = await fetch('/api/reports/42', {
  credentials: 'include',
  headers: { Accept: 'application/pdf' }
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const bytes = await response.arrayBuffer();
const pdfDocument = await pdfjsLib.getDocument({ data: bytes }).promise;

Cross-origin PDF

The PDF host must send CORS headers permitting the page origin. If it does not, proxy the file through your own server after enforcing authorization. A browser cannot bypass another origin’s access policy from client-side JavaScript.

6. Add the controls your audience actually needs

  • Navigation: previous/next buttons, a page number field, and a page count.
  • Zoom: fixed percentages plus fit-to-width for small screens.
  • Search: use the PDF.js text-content APIs or the packaged viewer’s search UI.
  • Thumbnails and outline: useful for long documents; render lazily so opening the first page stays quick.
  • Download and print: provide explicit actions only when your authorization model permits them.
  • Annotations and forms: verify the exact PDF.js version and feature requirements; do not assume every interactive PDF behaves like a static document.

Keep loading, empty, and error states visible. A document that has zero pages, a revoked URL, or a malformed stream should produce a message and recovery action rather than an empty canvas.

7. Responsive and accessibility checklist

  • Give the viewer a height that works on desktop and mobile.
  • Make controls keyboard reachable and expose their purpose with labels.
  • Use an aria-live status for page changes and loading errors.
  • Keep focus visible after opening dialogs or search panels.
  • Test narrow screens, high zoom, touch input, and landscape orientation.
  • Test real PDFs with selectable text, scanned pages, very large pages, rotated pages, forms, and encrypted files.
  • Check the browser and assistive-technology combinations your application supports. The cited sources do not establish a universal browser matrix or accessibility conformance level, so validate your own deployment.

8. Packaged viewer option

If you need a complete reader UI, start from the PDF.js web/ viewer and keep its corresponding CSS, JavaScript, locale, image, and worker assets together. Customize its branding and layout instead of shipping an untouched copy.

PDF.js Express documents a responsive viewer with zoom, thumbnails, search, text selection, and annotation-related products. Its documentation also describes an iframe-based UI: a separately hosted cross-origin iframe limits direct script access, so configuration or postMessage may be needed. Review its current license, free-versus-paid capabilities, and key requirements before adopting it.

9. Troubleshooting

Symptom Likely cause Fix
Worker warning or pages never render The worker URL is wrong or the page was opened with file://. Serve the site over HTTP and set GlobalWorkerOptions.workerSrc to the deployed worker path.
CORS error The PDF is hosted on another origin without permission. Configure CORS on the PDF host or fetch through an authorized same-origin backend.
Unexpected token or invalid PDF The URL returned HTML, a login page, or an error response. Inspect the network response, status, content type, redirects, and authentication.
Blank viewer with no scrollbar The viewer container has no usable height. Set a viewport-based or otherwise explicit height and allow overflow scrolling.
Only some pages fail The file may contain unusual fonts, damaged objects, encryption, or unsupported interactive content. Capture the PDF.js console error, test another file, and handle the failure per page.
Mobile layout is too wide Fixed page dimensions exceed the viewport. Add fit-to-width behavior, responsive padding, and touch-friendly controls.
Memory usage grows on long files Every page canvas is kept in the DOM. Render a window around the current page, remove distant canvases, and load thumbnails lazily.
Viewer controls cannot access an iframe The viewer is cross-origin. Host it on the same origin or use the vendor’s configuration and messaging mechanism.

10. Performance, reliability, and cost considerations

  • Render the first page before loading optional thumbnails, search indexes, or secondary panels.
  • Keep the worker on a cacheable, versioned URL and deploy matching main and worker files.
  • Use range requests and server support appropriate to your PDF.js version when serving large files; verify behavior with your hosting stack.
  • Limit concurrent page renders and release canvases that are outside the visible window.
  • Cache immutable PDFs and versioned viewer assets, but do not cache private documents in shared caches.
  • Measure startup time, first-page render time, memory, and failure rates with your actual documents and target browsers. The research sources provide no universal benchmark.
  • PDF.js itself is client-side software; your direct costs are hosting, bandwidth, and any backend needed for protected documents. Commercial viewers add their own license terms.

11. Or skip the browser setup

If your goal is to capture a rendered PDF viewer page as an image or PDF, ScreenshotNeo can handle the browser session through one request. It accepts a URL and returns PNG, JPEG, WebP, or PDF. See the ScreenshotNeo API documentation.

curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://example.com/pdf-viewer.html -o shot.webp
import requests
r = requests.get("https://api.screenshotneo.com/v1/shot", params={"access_key": "YOUR_API_KEY", "url": "https://example.com/pdf-viewer.html"}, timeout=90)
open("shot.webp", "wb").write(r.content)
const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://example.com/pdf-viewer.html' });
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);

Before capture, ScreenshotNeo accepts cookie and consent banners and removes more than 60 known consent platforms, newsletter popups, and chat widgets; each step can be turned off. Bot checks, blank pages, timeouts, failed loads, and cache hits are not billed, and response headers identify the page verdict and billing result. Its MCP server provides take_screenshot, get_page_info, and capture_pdf for Claude, Cursor, and other MCP clients. The Free plan includes 1,000 screenshots per month without a card; paid plans start at $5 for 3,000 shots.

Create a free ScreenshotNeo account and start with 1,000 screenshots per month at no charge.

12. FAQ

Can I open a PDF directly with an iframe?

Yes, when the browser’s built-in PDF viewer is acceptable. Use PDF.js when you need consistent controls, custom styling, or application-specific behavior.

Why does PDF.js need a worker?

The worker moves PDF parsing away from the main UI thread. Your deployment must expose the matching worker module at a valid URL.

Should I render every page immediately?

Usually no. Render the first page first and virtualize or lazily render additional pages for long documents.

Can a browser viewer bypass PDF permissions?

No. Network access, authentication, CORS, and server authorization still apply. Enforce document permissions on the server.

Is the example a finished accessible viewer?

No. It is a structural starting point. Verify keyboard behavior, focus management, screen-reader output, contrast, and the PDF types used by your audience.