Build a book reader with PDF.js: Two-page spread view tutorial

Table of contents

    Build a book reader with PDF.js: Two-page spread view tutorial
    TL;DR

    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

    Book reader with two-page spread view

    1. HTML setup

    Create a minimal HTML skeleton with a toolbar for page navigation and a reading mode toggle:

    index.html
    <!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 displayed
    • pagesPerSpread — 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:

    index.js
    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:

    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

    How do I display a PDF as a two-page spread?

    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.

    Can I add a cover page that displays alone?

    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.

    How do I add page turn animations?

    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.

    How do I add text search to a PDF.js book reader?

    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.

    Can I add annotations and highlights to the book reader?

    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.

    Hulya Masharipov

    Hulya Masharipov

    Technical Writer

    Hulya is a frontend web developer and technical writer who enjoys creating responsive, scalable, and maintainable web experiences. She’s passionate about open source, web accessibility, cybersecurity privacy, and blockchain.

    Explore related topics

    Try for free Ready to get started?