Document viewer
ProA document's pages, which the app renders, with labelled regions over them. Candor draws no PDF itself: each page is an image URL, or a canvas or other element the app passes (render it with pdf.js, an image service or anything else), sized by the page's own width and height, and each region is a box in those same units. One page shows at a time, with Previous and Next page and zoom against the page fitted to the width.
The regions are one stop for Tab. Arrow keys or J and K move between them in the order given, across pages, with a visible focus, and each is read by its label, its kind and, for one an agent proposed, the agent's name; Enter, Space or a click selects one and reports it through onRegionSelect. Selecting a region from outside (selected) turns to its page. A page still being drawn says so, and one the app couldn't draw says why and can offer Try again; its regions wait for it rather than floating over nothing. The page is not mirrored in right-to-left text, because it is the document's own layout; the controls are. Nothing animates.
Version 5.2.0· other versions
This page is for 5.2.0, the current release. An earlier release’s page is the Markdown that release published, so the docs match the version you have installed (candor-lock.json records it).
Not in the docs of 1.3.0, 1.4.0, 1.5.0, 1.5.1, 1.6.0, 1.7.0, 1.8.0, 1.8.1, 1.9.0, 1.9.1, 2.0.0, 2.1.0, 2.2.0, 2.3.0, 2.4.0, 2.5.0, 2.5.1, 2.6.0, 2.6.1, 2.6.2, 2.7.0 and 2.8.0: the page came later, or had another name then.
Docs pages weren’t kept for 1.0.0, 1.0.1, 1.1.0, 1.1.1, 1.2.0 and 1.2.1: they are kept from 1.3.0 on, and before that only llms.txt was published.
Preview
Loading the preview
Install
First time? Get started sets up the registry and theme. Or with shadcn directly: npx shadcn@latest add @candor/document-viewer
Brings in: bidi, button, locale, locked, skeleton, tokens, utils · npm: lucide-react@^1.67.0
Usage
"use client"
import * as React from "react"
import { DocumentViewer, type DocumentPage, type DocumentRegion } from "@/components/candor/document-viewer"
// In an app each page is drawn by its renderer (pdf.js onto a canvas, or an image from its server). Here an SVG stands
// in for the rendered page, in the page's own units: 612 × 792, a US Letter page in PDF points.
function InvoicePage() {
return (
<svg viewBox="0 0 612 792" aria-hidden className="bg-white text-neutral-800">
<text x="48" y="80" fontSize="28" fontWeight="600" fill="currentColor">Invoice</text>
<text x="400" y="74" fontSize="12" fill="currentColor">Date</text>
<text x="400" y="96" fontSize="14" fill="currentColor">3 November 2026</text>
<text x="48" y="150" fontSize="12" fill="currentColor">Bill to</text>
<text x="48" y="172" fontSize="14" fill="currentColor">Northwind Traders Ltd</text>
<text x="48" y="192" fontSize="12" fill="currentColor">12 Harbour Road, Dublin</text>
<rect x="48" y="250" width="516" height="200" fill="none" stroke="currentColor" strokeOpacity="0.3" />
{[0, 1, 2, 3].map((i) => (
<rect key={i} x="60" y={276 + i * 40} width={300 - i * 40} height="10" fill="currentColor" fillOpacity="0.15" />
))}
<text x="400" y="560" fontSize="12" fill="currentColor">Total due</text>
<text x="400" y="590" fontSize="20" fontWeight="600" fill="currentColor">€4,820.00</text>
</svg>
)
}
function TermsPage() {
return (
<svg viewBox="0 0 612 792" aria-hidden className="bg-white text-neutral-800">
<text x="48" y="80" fontSize="20" fontWeight="600" fill="currentColor">Terms</text>
{Array.from({ length: 14 }, (_, i) => (
<rect key={i} x="48" y={110 + i * 22} width={i % 4 === 3 ? 320 : 516} height="8" fill="currentColor" fillOpacity="0.15" />
))}
<line x1="60" y1="730" x2="260" y2="730" stroke="currentColor" strokeOpacity="0.5" />
<text x="60" y="748" fontSize="11" fill="currentColor">Signed for Northwind Traders Ltd</text>
</svg>
)
}
const pages: DocumentPage[] = [
{ id: "p1", width: 612, height: 792, content: <InvoicePage />, alt: "Invoice INV-2041, page 1" },
{ id: "p2", width: 612, height: 792, content: <TermsPage />, alt: "Invoice INV-2041, page 2" },
{ id: "p3", width: 612, height: 792, state: "error", error: "The renderer stopped on this page: the file is damaged after page 2." },
]
const regions: DocumentRegion[] = [
{ id: "date", page: "p1", box: { x: 392, y: 58, width: 170, height: 48 }, label: "Invoice date", kind: "field", proposed: true },
{ id: "customer", page: "p1", box: { x: 40, y: 134, width: 260, height: 66 }, label: "Customer", kind: "field", proposed: true },
{ id: "lines", page: "p1", box: { x: 48, y: 250, width: 516, height: 200 }, label: "Line items", kind: "table", proposed: true },
{ id: "total", page: "p1", box: { x: 392, y: 544, width: 170, height: 56 }, label: "Total due", kind: "field", proposed: true },
{ id: "signature", page: "p2", box: { x: 52, y: 690, width: 220, height: 66 }, label: "Signature", kind: "signature" },
]
export default function Example() {
// The viewer holds the selection itself, starting at Total due (`defaultSelected`), and the app only hears each change
// through `onRegionSelect`. To hold it in the app instead (to link it with a list), pass `selected` as well.
const [heard, setHeard] = React.useState("total")
const label = regions.find((r) => r.id === heard)?.label
return (
<div className="flex flex-col gap-2">
<DocumentViewer
label="Invoice INV-2041.pdf"
pages={pages}
regions={regions}
agent="Intake agent"
defaultSelected="total"
onRegionSelect={(r) => setHeard(r.id)}
className="h-[32rem] max-w-xl"
/>
<p className="text-sm text-muted-foreground">The viewer holds the selection. Last selected: {label}</p>
</div>
)
}<DocumentViewer>
| Prop | Type | Default | Description |
|---|---|---|---|
| pagesrequired | DocumentPage[] | — | The pages, in order. |
| regions | DocumentRegion[] | undefined | [] | The labelled regions, in reading order: the keyboard follows it. A region on a page not in `pages` is left out. |
| selected | string | null | undefined | — | The selected region's id, when the app holds the selection (to link it with a list, say). `null` for none. |
| defaultSelected | string | null | undefined | — | The region selected to begin with when the app doesn't hold the selection (`null` for none); the viewer keeps it from there. Ignored while `selected` is set. |
| onRegionSelect | ((region: DocumentRegion) => void) | undefined | — | Called with the region the person selected, by click, Enter or Space. |
| defaultPage | string | undefined | — | The id of the page to open on. Defaults to the first. |
| onPageChange | ((pageId: string) => void) | undefined | — | Called with a page's id each time the page shown changes (not for the first one), so the app can draw only the page on screen, as pdf.js advises. Mark a page `state: "loading"` while it draws. |
| onRetry | ((pageId: string) => void) | undefined | — | Adds Try again to a page that couldn't be drawn; called with its id. |
| agent | string | undefined | — | The agent that proposed regions, named by the app ("Intake agent"). Never model text. Left out, they say "an agent". |
| label | string | undefined | — | The document's name, as the viewer's accessible name ("Invoice INV-2041.pdf"). Left out, it reads "Document". |
| className | string | undefined | — | Extra classes for the outer element. |
Strings
The fixed words, in English by default. Change any of them under documentViewer in <LocaleProvider strings={{ … }}>; the table is exported as documentViewerStrings.
| Key | English |
|---|---|
| label | Document |
| page | Page {page} of {count} |
| previousPage | Previous page |
| nextPage | Next page |
| firstPage | This is the first page. |
| lastPage | This is the last page. |
| zoomIn | Zoom in |
| zoomOut | Zoom out |
| fitWidth | Fit to width |
| zoomLevel | Zoom {percent} |
| mostZoomed | Zoomed in as far as it goes. |
| leastZoomed | Zoomed out as far as it goes. |
| fitted | The page already fits the width. |
| regions | Regions on page {page} |
| regionCount | { zero: "No regions on this page", one: "{count} region on this page", other: "{count} regions on this page" } |
| keys | Arrow keys or J and K move between regions, Home and End go to the first and last, Enter selects. |
| regionKind | {label}, {kind} |
| proposedBy | {name}, proposed by {agent} |
| proposedUnnamed | {name}, proposed by an agent |
| proposedMark | {agent}'s |
| proposedMarkUnnamed | Agent's |
| field | field |
| table | table |
| signature | signature |
| text | text |
| figure | figure |
| loading | Page {page} is loading. |
| error | Page {page} couldn't be shown. |
| imageError | The image for page {page} didn't load. |
| retry | Try again |
| regionsWait | { one: "Its region shows once the page is drawn.", other: "Its {count} regions show once the page is drawn." } |
| empty | There are no pages to show. |
Types
- RegionKind
- What kind of thing a region marks. Left out, a region is only its label.
"field" | "table" | "signature" | "text" | "figure"- RegionBox
- A rectangle in a page's own coordinates (the same units as the page's `width` and `height`), from its top-left corner.
{ /** From the page's left edge. */ x: number; /** From the page's top edge. */ y: number; width: number; height: number; }- DocumentPage
- One page of the document, drawn by the app.
{ /** Stable for this page; regions name it in `page`. */ id: string; /** The page's width in its own units: PDF points, or the image's pixels. Regions use the same units. */ width: number; /** The page's height in the same units. */ height: number; /** An image of the page the app rendered (a PNG from its server, a data URL from pdf.js). */ src?: string | undefined; /** * What the app drew instead of an image: a canvas, an `<img>`, an SVG, any element. It fills the page's box, so size * it to 100% of its width and height. Used when `src` is left out. */ content?: React.ReactNode | undefined; /** The page's text alternative, e.g. "Invoice INV-2041, page 1". Left out, the page is named by its number. */ alt?: string | undefined; /** "loading" while the app is still drawing it, "error" when it couldn't. Left out, the page is ready. */ state?: "loading" | "error" | undefined; /** Why the page couldn't be drawn, in the app's words ("The file is password-protected."), shown under the error. */ error?: string | undefined; }- DocumentRegion
- A labelled area of a page.
{ /** Stable for this region; `selected` and `onRegionSelect` use it. */ id: string; /** The id of the page it is on. */ page: string; /** Where it is, in the page's own coordinates. */ box: RegionBox; /** What it is, e.g. "Invoice total". Shown as text, never as HTML. */ label: string; /** What kind of thing it marks: a field, a table, a signature, a block of text or a figure. */ kind?: RegionKind | undefined; /** * True when an agent proposed the region (an extraction says it found something there), rather than the document * or a person marking it. It is drawn dashed and named as the agent's, because it is the agent's claim about the page. */ proposed?: boolean | undefined; }