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.