Skip to main content
Back to Blog
Tutorials
3 min read
December 10, 2024

How to Build a PDF Viewer Component in React

Build a PDF viewer component with page navigation, zoom controls, thumbnails, and text selection using react-pdf in React.

Ryel Banfield

Founder & Lead Developer

PDF viewing is essential for legal, accounting, and document management applications. Here is how to build a feature-rich viewer with react-pdf.

Install Dependencies

pnpm add react-pdf

Configure the Worker

The PDF.js worker needs to be loaded. Copy it to public:

// lib/pdf-worker.ts
import { pdfjs } from "react-pdf";

pdfjs.GlobalWorkerOptions.workerSrc = new URL(
  "pdfjs-dist/build/pdf.worker.min.mjs",
  import.meta.url
).toString();

The PDF Viewer Component

"use client";

import { useCallback, useState } from "react";
import { Document, Page } from "react-pdf";
import "react-pdf/dist/Page/AnnotationLayer.css";
import "react-pdf/dist/Page/TextLayer.css";
import "@/lib/pdf-worker";

interface PdfViewerProps {
  url: string;
  title?: string;
}

export function PdfViewer({ url, title }: PdfViewerProps) {
  const [numPages, setNumPages] = useState(0);
  const [currentPage, setCurrentPage] = useState(1);
  const [scale, setScale] = useState(1.0);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);

  const onDocumentLoadSuccess = useCallback(
    ({ numPages }: { numPages: number }) => {
      setNumPages(numPages);
      setLoading(false);
    },
    []
  );

  const onDocumentLoadError = useCallback((err: Error) => {
    setError(err.message);
    setLoading(false);
  }, []);

  const goToPage = useCallback(
    (page: number) => {
      setCurrentPage(Math.max(1, Math.min(page, numPages)));
    },
    [numPages]
  );

  const zoomIn = useCallback(() => {
    setScale((prev) => Math.min(prev + 0.25, 3.0));
  }, []);

  const zoomOut = useCallback(() => {
    setScale((prev) => Math.max(prev - 0.25, 0.5));
  }, []);

  const resetZoom = useCallback(() => {
    setScale(1.0);
  }, []);

  return (
    <div className="flex flex-col h-full border rounded-lg overflow-hidden bg-muted/30">
      {/* Toolbar */}
      <div className="flex items-center justify-between px-4 py-2 border-b bg-background">
        <div className="flex items-center gap-2">
          {title && <span className="text-sm font-medium truncate max-w-48">{title}</span>}
        </div>

        {/* Page Navigation */}
        <div className="flex items-center gap-2">
          <button
            onClick={() => goToPage(currentPage - 1)}
            disabled={currentPage <= 1}
            className="p-1.5 rounded hover:bg-muted disabled:opacity-30"
            aria-label="Previous page"
          >
            <svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
              <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
            </svg>
          </button>

          <div className="flex items-center gap-1 text-sm">
            <input
              type="number"
              min={1}
              max={numPages}
              value={currentPage}
              onChange={(e) => goToPage(Number(e.target.value))}
              className="w-12 text-center border rounded px-1 py-0.5 text-sm"
              aria-label="Current page"
            />
            <span className="text-muted-foreground">/ {numPages}</span>
          </div>

          <button
            onClick={() => goToPage(currentPage + 1)}
            disabled={currentPage >= numPages}
            className="p-1.5 rounded hover:bg-muted disabled:opacity-30"
            aria-label="Next page"
          >
            <svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
              <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
            </svg>
          </button>
        </div>

        {/* Zoom Controls */}
        <div className="flex items-center gap-1">
          <button
            onClick={zoomOut}
            disabled={scale <= 0.5}
            className="p-1.5 rounded hover:bg-muted disabled:opacity-30"
            aria-label="Zoom out"
          >
            <svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
              <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20 12H4" />
            </svg>
          </button>

          <button
            onClick={resetZoom}
            className="px-2 py-0.5 text-xs rounded hover:bg-muted min-w-12 text-center"
          >
            {Math.round(scale * 100)}%
          </button>

          <button
            onClick={zoomIn}
            disabled={scale >= 3.0}
            className="p-1.5 rounded hover:bg-muted disabled:opacity-30"
            aria-label="Zoom in"
          >
            <svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
              <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
            </svg>
          </button>

          {/* Download */}
          <a
            href={url}
            download
            className="p-1.5 rounded hover:bg-muted ml-2"
            aria-label="Download PDF"
          >
            <svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
              <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
            </svg>
          </a>
        </div>
      </div>

      {/* Document Area */}
      <div className="flex-1 overflow-auto flex justify-center p-4">
        {error ? (
          <div className="flex items-center justify-center h-full">
            <div className="text-center">
              <p className="text-red-500 font-medium">Failed to load PDF</p>
              <p className="text-sm text-muted-foreground mt-1">{error}</p>
            </div>
          </div>
        ) : (
          <Document
            file={url}
            onLoadSuccess={onDocumentLoadSuccess}
            onLoadError={onDocumentLoadError}
            loading={
              <div className="flex items-center justify-center h-64">
                <div className="h-8 w-8 border-2 border-primary border-t-transparent rounded-full animate-spin" />
              </div>
            }
          >
            <Page
              pageNumber={currentPage}
              scale={scale}
              renderTextLayer
              renderAnnotationLayer
              loading={
                <div className="h-[800px] w-[600px] animate-pulse bg-muted rounded" />
              }
            />
          </Document>
        )}
      </div>

      {/* Keyboard Shortcuts */}
      <PdfKeyboardShortcuts
        onPrev={() => goToPage(currentPage - 1)}
        onNext={() => goToPage(currentPage + 1)}
        onZoomIn={zoomIn}
        onZoomOut={zoomOut}
        onResetZoom={resetZoom}
      />
    </div>
  );
}

Keyboard Shortcuts

"use client";

import { useEffect } from "react";

interface PdfKeyboardShortcutsProps {
  onPrev: () => void;
  onNext: () => void;
  onZoomIn: () => void;
  onZoomOut: () => void;
  onResetZoom: () => void;
}

function PdfKeyboardShortcuts({
  onPrev,
  onNext,
  onZoomIn,
  onZoomOut,
  onResetZoom,
}: PdfKeyboardShortcutsProps) {
  useEffect(() => {
    function handleKeyDown(e: KeyboardEvent) {
      // Skip if user is typing in an input
      if (
        e.target instanceof HTMLInputElement ||
        e.target instanceof HTMLTextAreaElement
      ) {
        return;
      }

      switch (e.key) {
        case "ArrowLeft":
        case "PageUp":
          e.preventDefault();
          onPrev();
          break;
        case "ArrowRight":
        case "PageDown":
          e.preventDefault();
          onNext();
          break;
        case "+":
        case "=":
          if (e.metaKey || e.ctrlKey) {
            e.preventDefault();
            onZoomIn();
          }
          break;
        case "-":
          if (e.metaKey || e.ctrlKey) {
            e.preventDefault();
            onZoomOut();
          }
          break;
        case "0":
          if (e.metaKey || e.ctrlKey) {
            e.preventDefault();
            onResetZoom();
          }
          break;
      }
    }

    window.addEventListener("keydown", handleKeyDown);
    return () => window.removeEventListener("keydown", handleKeyDown);
  }, [onPrev, onNext, onZoomIn, onZoomOut, onResetZoom]);

  return null;
}

Thumbnail Sidebar

"use client";

import { Document, Page, Thumbnail } from "react-pdf";

interface ThumbnailSidebarProps {
  url: string;
  numPages: number;
  currentPage: number;
  onPageSelect: (page: number) => void;
}

function ThumbnailSidebar({
  url,
  numPages,
  currentPage,
  onPageSelect,
}: ThumbnailSidebarProps) {
  return (
    <div className="w-36 border-r overflow-y-auto bg-muted/30 p-2 space-y-2">
      <Document file={url}>
        {Array.from({ length: numPages }, (_, i) => i + 1).map((page) => (
          <button
            key={page}
            onClick={() => onPageSelect(page)}
            className={`w-full rounded-md overflow-hidden border-2 transition-colors ${
              page === currentPage
                ? "border-primary"
                : "border-transparent hover:border-muted-foreground/30"
            }`}
          >
            <Page
              pageNumber={page}
              width={120}
              renderTextLayer={false}
              renderAnnotationLayer={false}
            />
            <div className="text-xs text-center py-1 text-muted-foreground">
              {page}
            </div>
          </button>
        ))}
      </Document>
    </div>
  );
}

Usage

import { PdfViewer } from "@/components/PdfViewer";

export default function ContractPage() {
  return (
    <div className="h-[80vh]">
      <PdfViewer
        url="/documents/contract.pdf"
        title="Service Agreement"
      />
    </div>
  );
}

Need Document Management Features?

We build document viewers, annotation tools, and e-signature workflows for business applications. Contact us to add document handling to your app.

PDFviewerreact-pdfdocumentReacttutorial

Ready to Start Your Project?

RCB Software builds world-class websites and applications for businesses worldwide.

Get in Touch

Related Articles