Doc Viewer
A compact document viewer: page thumbnails, page-by-page navigation, zoom steps and fit-width, with a page jump field.
$
View as Markdownnpx shadcn@latest add https://sundaeui.dev/r/doc-viewer.json'use client'
import { useState } from 'react'
import './doc-viewer.css'
export interface DocViewerProps {
pages: React.ReactNode[]
title?: string
defaultZoom?: number
thumbs?: boolean
fill?: 'dark' | 'light'
className?: string
}
const ZOOMS = [50, 75, 100, 125, 150, 200]
/** Paged viewer with thumbnails and zoom. */
export function DocViewer({ pages, title = 'document.pdf', defaultZoom = 100, thumbs = true, fill = 'dark', className }: DocViewerProps) {
const [p, setP] = useState(0)
const [z, setZ] = useState<number | 'fit'>(defaultZoom)
const [draft, setDraft] = useState('')
const go = (n: number) => { setP(Math.max(0, Math.min(pages.length - 1, n))); setDraft('') }
const zi = z === 'fit' ? 2 : ZOOMS.indexOf(z)
const zoom = (d: number) => setZ(ZOOMS[Math.max(0, Math.min(ZOOMS.length - 1, zi + d))])
const width = z === 'fit' ? '100%' : `${(z / 100) * 190}px`
return (
<div className={['sd-dcv', className].filter(Boolean).join(' ')} data-fill={fill} tabIndex={-1}
onKeyDown={(e) => { if ((e.target as HTMLElement).tagName === 'INPUT') return; if (e.key === 'ArrowRight') go(p + 1); if (e.key === 'ArrowLeft') go(p - 1); if (e.key === '+' || e.key === '=') zoom(1); if (e.key === '-') zoom(-1) }}>
<div className="sd-dcv__bar">
<b>{title}</b>
<button type="button" className="sd-dcv__ib" aria-label="Previous page" disabled={p === 0} onClick={() => go(p - 1)}>‹</button>
<span className="sd-dcv__pg"><input type="number" aria-label="Page" value={draft || p + 1} min={1} max={pages.length} onChange={(e) => setDraft(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') go(Number(draft) - 1) }} onBlur={() => draft && go(Number(draft) - 1)} />/ {pages.length}</span>
<button type="button" className="sd-dcv__ib" aria-label="Next page" disabled={p === pages.length - 1} onClick={() => go(p + 1)}>›</button>
<button type="button" className="sd-dcv__ib" aria-label="Zoom out" disabled={z !== 'fit' && zi === 0} onClick={() => zoom(-1)}>−</button>
<button type="button" className="sd-dcv__ib" aria-label={`Zoom ${z === 'fit' ? 'fit width' : z + '%'}. Toggle fit`} onClick={() => setZ(z === 'fit' ? 100 : 'fit')} style={{ fontSize: 11.5, fontVariantNumeric: 'tabular-nums' }}>{z === 'fit' ? 'Fit' : `${z}%`}</button>
<button type="button" className="sd-dcv__ib" aria-label="Zoom in" disabled={z !== 'fit' && zi === ZOOMS.length - 1} onClick={() => zoom(1)}>+</button>
</div>
<div className="sd-dcv__body">
{thumbs && (
<ul className="sd-dcv__th" aria-label="Pages">
{pages.map((_, i) => <li key={i}><button type="button" className="sd-dcv__t" aria-current={i === p} aria-label={`Page ${i + 1}`} onClick={() => go(i)}><span>{i + 1}</span></button></li>)}
</ul>
)}
<div className="sd-dcv__view" aria-live="polite">
<div key={p} className="sd-dcv__page" style={{ width, fontSize: z === 'fit' ? 13 : (z / 100) * 11 }} aria-label={`Page ${p + 1} of ${pages.length}`} role="img">{pages[p]}</div>
</div>
</div>
</div>
)
}.sd-dcv { --bg: #1c1c1c; --fg: #f6f6f6; --mute: #8e8e8e; --line: rgb(255 255 255 / 0.1); --soft: rgb(255 255 255 / 0.07); --strong: #fafafa; --on: #111;
color: var(--fg); font-size: 14px; line-height: 1.4; -webkit-tap-highlight-color: transparent; }
.sd-dcv[data-fill='light'] { --bg: #fff; --fg: #121212; --mute: #6f6f6f; --line: rgb(0 0 0 / 0.09); --soft: rgb(0 0 0 / 0.05); --strong: #111; --on: #fff; }
.sd-dcv :where(button) { font: inherit; color: inherit; background: none; border: 0; padding: 0; cursor: pointer; }
.sd-dcv :focus-visible { outline: 2px solid var(--fg); outline-offset: 2px; }
.sd-dcv .sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.sd-dcv { width: min(100%, 360px); border-radius: 16px; background: var(--bg); box-shadow: inset 0 0 0 1px var(--line); overflow: hidden; outline: 0; }
.sd-dcv__bar { display: flex; align-items: center; gap: 4px; padding: 6px 8px; border-bottom: 1px solid var(--line); font-size: 12.5px; }
.sd-dcv__bar b { flex: 1; min-width: 0; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; padding-left: 4px; }
.sd-dcv__ib { display: grid; place-items: center; min-width: 28px; height: 28px; padding: 0 6px; border-radius: 7px; font-size: 13px; }
.sd-dcv__ib:hover:not(:disabled) { background: var(--soft); }
.sd-dcv__ib:disabled { opacity: 0.3; }
.sd-dcv__pg { display: flex; align-items: center; gap: 4px; color: var(--mute); font-variant-numeric: tabular-nums; }
.sd-dcv__pg input { width: 32px; height: 26px; border: 0; border-radius: 6px; background: var(--soft); color: var(--fg); font: inherit; text-align: center; outline: 0; -moz-appearance: textfield; }
.sd-dcv__pg input::-webkit-inner-spin-button { -webkit-appearance: none; }
.sd-dcv__body { display: flex; height: 360px; }
.sd-dcv__th { flex: none; width: 64px; padding: 8px; overflow-y: auto; border-right: 1px solid var(--line); list-style: none; margin: 0; display: grid; gap: 8px; align-content: start; }
.sd-dcv__t { width: 100%; aspect-ratio: 0.77; border-radius: 4px; background: #fafaf8 !important; box-shadow: inset 0 0 0 1px var(--line); position: relative; overflow: hidden; }
.sd-dcv__t[aria-current='true'] { box-shadow: 0 0 0 2px var(--fg); }
.sd-dcv__t span { position: absolute; bottom: 2px; right: 4px; font-size: 10.5px; color: #555; font-weight: 600; }
.sd-dcv__view { flex: 1; overflow: auto; padding: 14px; background: var(--soft); }
.sd-dcv__page { margin: 0 auto; aspect-ratio: 0.77; background: #fafaf8; color: #222; border-radius: 3px; box-shadow: 0 6px 18px -8px rgb(0 0 0 / 0.5); transition: width 200ms; padding: 8%; overflow: hidden; animation: sd-dcv-in 200ms ease-out; }
.sd-dcv__page h4 { margin: 0 0 6%; font-size: 1em; }
.sd-dcv__ln { height: 0.35em; margin-bottom: 0.55em; border-radius: 2px; background: #d8d8d4; }
@keyframes sd-dcv-in { from { opacity: 0.4; } }
@media (prefers-reduced-motion: reduce) { .sd-dcv *, .sd-dcv *::before, .sd-dcv *::after { animation: none !important; transition: none !important; } }Overview
Thumbnails on the side mark the current page and jump on click. Arrows and the page field move through the document; zoom steps or fit to width.
Pages are placeholders drawn with CSS here; pass rendered PDF pages or images as children of each page.
When to use
- Contracts, invoices, uploaded files.
When not to use
- Long-form reading: render as HTML.
Installation
$
pnpm dlx shadcn@latest add https://sundaeui.dev/r/doc-viewer.json$
npx shadcn@latest add https://sundaeui.dev/r/doc-viewer.json$
yarn dlx shadcn@latest add https://sundaeui.dev/r/doc-viewer.json$
bunx --bun shadcn@latest add https://sundaeui.dev/r/doc-viewer.jsonWorks in any React project. Files are written to components/sundae/. Read the source in the stage’s Code view.
- No dependencies beyond React 18 or later.
- Copy doc-viewer.tsx and doc-viewer.css into components/sundae/.
- The CSS file is imported by the component, so no extra configuration is needed.
- Import the component and use it as shown below.
Usage
import { DocViewer } from '@/components/sundae/doc-viewer'
<DocViewer />Props
| Prop | Type | Default |
|---|---|---|
pages*Rendered pages. | ReactNode[] | — |
titleFile name. | string | — |
defaultZoomZoom percent. | number | 100 |
fillSurface: dark or light. | 'dark' | 'light' | 'dark' |
Accessibility
- Thumbnails are a list of buttons with aria-current on the visible page.
- Page field is a labelled number input; zoom buttons announce the level.
- Reduced motion removes the movement and keeps every state change.
Customization
- Colours are CSS custom properties on .sd-dcv; override them in your own stylesheet.
- fill="light" switches the built-in light palette.
Keyboard
- ← / →
- Previous / next page
- + / −
- Zoom
FAQ
- Is Doc Viewer free?
- Yes. Doc Viewer is free for personal and commercial projects, with full source on this page.
- How do I install Doc Viewer?
- Run npx shadcn@latest add https://sundaeui.dev/r/doc-viewer.json. The files are written to components/sundae/. It has no dependencies beyond React 18 or later.
- Does Doc Viewer support keyboard and screen readers?
- Thumbnails are a list of buttons with aria-current on the visible page. Page field is a labelled number input; zoom buttons announce the level.
- Does Doc Viewer respect reduced motion?
- Reduced motion removes the movement and keeps every state change.
More like this
All components →- Pro
Zoom Canvas
Components · Refined Toast Stack
Components · Refined- Pro
Sheet Drawer
Components · Refined Avatar Stack
Components · Refined- Pro
Notification Center
Components · Refined Hold to Confirm
Components · Refined