Build a book reader with PDF.js: Two-page spread view tutorial
Table of contents
Build a book reader with PDF.js that displays pages side by side like a real book. This tutorial covers two-page spread rendering, book-style page flipping, and toggleable reading modes, useful for ebooks, magazines, and comics.
Reading PDFs page by page works for documents, but books and magazines work better as a two-page spread. In this tutorial, you’ll build a book reader with PDF.js(opens in a new tab) that supports both single-page and spread view modes. If you need a production-ready solution, Nutrient Web SDK includes spread view modes, annotations, digital signatures, an AI assistant, and real-time collaboration.
New to PDF.js? Start with our complete guide to PDF.js for the basics, then come back here.
What you’ll build
A book reader with:
- Two-page spread view — Display facing pages side by side
- Single-page mode — Toggle to a traditional one-page view
- Book-style navigation — Flip through page pairs
- Responsive layout — Pages resize to fit the viewport

1. HTML setup
Create a minimal HTML skeleton with a toolbar for page navigation and a reading mode toggle:
<!DOCTYPE html><html> <head> <title>PDF Book Reader</title> <meta charset="UTF-8" />47 collapsed lines
<style> * { box-sizing: border-box; }
body { margin: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; }
#viewport { display: flex; justify-content: center; gap: 4px; background: #333; padding: 20px; min-height: calc(100vh - 50px); align-items: flex-start; }
#toolbar { padding: 10px; background: #f5f5f5; display: flex; gap: 20px; align-items: center; }
.page { width: 45%; max-width: 500px; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3); }
.page canvas { display: block; width: 100%; height: auto; }
button { padding: 8px 16px; cursor: pointer; border: 1px solid #ccc; background: #fff; border-radius: 4px; }
button:hover { background: #e9e9e9; }
select { padding: 6px 10px; border-radius: 4px; border: 1px solid #ccc; } </style> </head>
<body> <div id="app"> <div role="toolbar" id="toolbar"> <div id="pager"> <button data-pager="prev">← Previous</button> <button data-pager="next">Next →</button> </div> <div id="page-mode"> <label> Pages per spread: <select id="spread-select"> <option value="1">Single page</option> <option value="2" selected>Two-page spread</option> </select> </label> </div> </div> <div id="viewport-container"> <div role="main" id="viewport"></div> </div> </div>
<script src="https://unpkg.com/pdfjs-dist@5.0.375/build/pdf.min.mjs" type="module" ></script> <script src="index.js" type="module"></script> </body></html>The key elements:
- Spread selector — Toggle between single-page and two-page spread modes
- Viewport with flexbox — Centers and displays pages side by side
- Dark background — Creates a book reader look
2. Loading PDF.js
Import PDF.js and configure the web worker for background rendering:
import * as pdfjsLib from 'https://unpkg.com/pdfjs-dist@5.0.375/build/pdf.min.mjs';
pdfjsLib.GlobalWorkerOptions.workerSrc = 'https://unpkg.com/pdfjs-dist@5.0.375/build/pdf.worker.min.mjs';3. Book reader state
Define variables to track the reading position and spread mode:
let currentSpreadIndex = 0; // Which spread we're viewing (0, 1, 2...).let pagesPerSpread = 2; // 1 = single page, 2 = two-page spread.let pdfDoc = null;let totalPages = 0;
const viewport = document.querySelector('#viewport');currentSpreadIndex— Which "spread" (pair of pages) is currently displayedpagesPerSpread— Controls single-page (1) or two-page spread (2) mode
4. Loading the book
Initialize the PDF and set up event listeners:
function initBookReader(pdfURL) { pdfjsLib.getDocument(pdfURL).promise.then((pdf) => { pdfDoc = pdf; totalPages = pdf.numPages; initControls(); renderSpread(); });}5. Book-style navigation
Navigate through spreads (page pairs) rather than individual pages:
function initControls() { // Page flip buttons. document.querySelector('#pager').addEventListener('click', (e) => { const action = e.target.getAttribute('data-pager'); if (action === 'prev' && currentSpreadIndex > 0) { currentSpreadIndex--; renderSpread(); } if (action === 'next') { const maxSpread = Math.ceil(totalPages / pagesPerSpread) - 1; if (currentSpreadIndex < maxSpread) { currentSpreadIndex++; renderSpread(); } } });
// Spread mode toggle. document.querySelector('#spread-select').addEventListener('change', (e) => { pagesPerSpread = Number(e.target.value); currentSpreadIndex = 0; // Reset to first spread renderSpread(); });}The key difference from a standard PDF viewer: We navigate by spreads, not pages. In two-page mode, clicking Next advances by two pages.
6. Rendering the spread
Calculate which pages belong to the current spread and render them side by side:
function renderSpread() { const startPage = currentSpreadIndex * pagesPerSpread; const endPage = Math.min(startPage + pagesPerSpread - 1, totalPages - 1);
// Fetch all pages in this spread. const pagePromises = []; for (let i = startPage; i <= endPage; i++) { pagePromises.push(pdfDoc.getPage(i + 1)); // PDF.js uses 1-based indexing }
Promise.all(pagePromises).then((pages) => { // Create canvas containers for each page. viewport.innerHTML = pages .map(() => `<div class="page"><canvas></canvas></div>`) .join('');
// Render each page. pages.forEach((page, index) => { renderPage(page, viewport.children[index]); }); });}
function renderPage(page, container) { const canvas = container.querySelector('canvas'); const context = canvas.getContext('2d');
// Scale to fit container width. let pdfViewport = page.getViewport({ scale: 1 }); const scale = container.offsetWidth / pdfViewport.width; pdfViewport = page.getViewport({ scale });
canvas.width = pdfViewport.width; canvas.height = pdfViewport.height;
page.render({ canvasContext: context, viewport: pdfViewport });}In two-page spread mode, the viewport displays both pages side by side using flexbox.
7. Complete code
Here’s the full book reader implementation:
import * as pdfjsLib from 'https://unpkg.com/pdfjs-dist@5.0.375/build/pdf.min.mjs';
pdfjsLib.GlobalWorkerOptions.workerSrc = 'https://unpkg.com/pdfjs-dist@5.0.375/build/pdf.worker.min.mjs';
// Book reader state.let currentSpreadIndex = 0;let pagesPerSpread = 2;let pdfDoc = null;let totalPages = 0;
const viewport = document.querySelector('#viewport');
// Initialize the book reader.function initBookReader(pdfURL) {31 collapsed lines
pdfjsLib.getDocument(pdfURL).promise.then((pdf) => { pdfDoc = pdf; totalPages = pdf.numPages; initControls(); renderSpread(); });}
// Set up navigation and spread mode controls.function initControls() { document.querySelector('#pager').addEventListener('click', (e) => { const action = e.target.getAttribute('data-pager'); if (action === 'prev' && currentSpreadIndex > 0) { currentSpreadIndex--; renderSpread(); } if (action === 'next') { const maxSpread = Math.ceil(totalPages / pagesPerSpread) - 1; if (currentSpreadIndex < maxSpread) { currentSpreadIndex++; renderSpread(); } } });
document.querySelector('#spread-select').addEventListener('change', (e) => { pagesPerSpread = Number(e.target.value); currentSpreadIndex = 0; renderSpread(); });}
// Render the current spread (one or two pages).function renderSpread() {21 collapsed lines
const startPage = currentSpreadIndex * pagesPerSpread; const endPage = Math.min(startPage + pagesPerSpread - 1, totalPages - 1);
const pagePromises = []; for (let i = startPage; i <= endPage; i++) { pagePromises.push(pdfDoc.getPage(i + 1)); }
Promise.all(pagePromises).then((pages) => { viewport.innerHTML = pages .map(() => `<div class="page"><canvas></canvas></div>`) .join('');
pages.forEach((page, index) => { renderPage(page, viewport.children[index]); }); });}
// Render a single page to its canvas.function renderPage(page, container) { const canvas = container.querySelector('canvas'); const context = canvas.getContext('2d');
let pdfViewport = page.getViewport({ scale: 1 }); const scale = container.offsetWidth / pdfViewport.width; pdfViewport = page.getViewport({ scale });
canvas.width = pdfViewport.width; canvas.height = pdfViewport.height;
page.render({ canvasContext: context, viewport: pdfViewport });}
// Start the book reader.initBookReader('book.pdf');Add this CSS for proper page sizing:
.page { width: 45%; max-width: 500px; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);}Next steps
This tutorial covered the basics of building a book reader with PDF.js. For more features:
- Learn PDF.js fundamentals — See our complete guide to PDF.js for the full API.
- Add annotations — Explore how to add annotations for highlighting and notes.
- Commercial viewer — Try Nutrient Web SDK for built-in spread view, annotations, and more.
Conclusion
You’ve built a book reader that displays PDFs in a two-page spread layout.
PDF.js handles the rendering, but building production features (annotations, bookmarks, search) takes more code. For a ready-to-use book reader with these features included, check out Nutrient Web SDK, which includes spread view modes, annotations, and real-time collaboration.
FAQ
Set pagesPerSpread = 2 and render two consecutive pages side by side using a flexbox. The spread index determines which pair of pages to display. Nutrient Web SDK offers built-in spread view modes that handle this automatically.
Yes. Check if the current spread is the first one (currentSpreadIndex === 0) and render only page 1. Then start two-page spreads from pages 2–3 onward. Nutrient Web SDK supports cover page mode with a single configuration option.
PDF.js renders to canvas, so you’ll need a JavaScript animation library like GSAP or CSS transitions on the canvas containers. Consider a library like turn.js for realistic page-flip effects. Nutrient Web SDK includes smooth page transitions and scrolling modes built in.
Use PDF.js’s getTextContent() method to extract text from each page. Then build a search index. Highlighting matches requires calculating text positions and overlaying elements on the canvas. Nutrient Web SDK includes full-text search with highlighting built in.
PDF.js can display existing annotations but doesn’t support creating new ones. You’d need to build a custom annotation layer on top of the canvas and handle saving/loading yourself. Nutrient Web SDK provides annotations, including highlights, notes, and drawings.