OCR Viewer
A scanned page beside the text read off it, kept in sync both ways — hover or focus a line to light up its source box, click a box to focus its line, with low-confidence words underlined in place.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/ocr-viewer.jsonPrompt
Build a React + TypeScript + Tailwind "OcrViewer" component with zod, lucide-react
and a shadcn Button + Tooltip. Two panes side by side: the scanned page, and the
text an OCR engine read off it, wired together in both directions.
Contract
- A zod schema in a sibling contract file is the single source of truth.
Page: { id, imageUrl, label?, aspectRatio?, regions: Region[] }.
Region: { id, bbox, text, confidence, words?: { text, confidence }[] }.
Box: { x, y, width, height } in PERCENT of the page image (0–100), never
pixels — percentages are what let one payload sit on the same words at any
rendered size. If the engine emits pixels, divide once in the data layer.
- Region array order IS reading order. Never re-sort it: reading order is a
document fact the extractor knows and a renderer can only guess at. Both panes
print the same 1-based ordinal, which is the only thing tying a box to a line
for a reader who is not hovering anything.
- `words` is optional. Without it, the whole region is treated as ONE token at
the region's confidence, so a line-level engine gets a coarser marking instead
of a fabricated per-word one.
- Envelope status — "loading" | "empty" | "error" | "ready" — is the viewer's own
state and says nothing about recognition quality: a page whose every word is a
40 % guess is still `ready`, and `error` means the OCR job or its fetch failed.
`status="ready"` with zero pages renders the empty branch.
- Props: status; pages; page? / defaultPage? / onPageChange(index, page);
selectedRegionId? / onRegionSelect(region | null, page); lowConfidenceThreshold?
(0.8); copyScope? ("page" | "document"); onCopy(text, scope); showConfidence?
(true); formatConfidence?; emptyState?; errorMessage?; onRetry?; label?
("Scanned document"); className plus the rest of the div props, ref forwarded
to the root. Both `page` and `selectedRegionId` are controllable, each with an
internal fallback.
Behavior
- TWO ENTRY POINTS, ONE HIGHLIGHT. Hover is transient (`hoveredId`), selection is
the anchor (`selectedId`); a region paints as active when either matches, and
both panes read the same two ids, so the sync is a consequence of the data
model rather than a pair of effects chasing each other.
- The text pane is a real listbox: role="listbox" with role="option" rows,
aria-selected, and a ROVING tab order (exactly one row is tabbable — the
selected one, or the first). Selection follows focus, so "focus a line" and
"highlight its box" are literally the same event. ArrowUp/Down move and clamp
at the ends, Home/End jump, Escape clears the selection and fires
onRegionSelect(null).
- The boxes on the scan are POINTER-ONLY: tabIndex={-1} inside an aria-hidden
overlay. The same region must not be two tab stops away from itself, and the
text pane already exposes every one of them to the keyboard and the screen
reader. Clicking a box calls preventDefault on mousedown so the click cannot
steal focus, then moves focus to the matching row and scrolls it into view with
block:"nearest" — a row already visible does not move, so the reveal never
yanks the page out from under the reader. Hovering a box never scrolls
anything.
- Guard the focus echo. `focus()` dispatches synchronously, so the row's own
onFocus would re-enter the selection path with a closure still holding the
pre-click value and fire onRegionSelect TWICE for one click. Raise a ref while
you move focus and have onFocus bail out on it.
- Words at or below `lowConfidenceThreshold` get a dotted destructive underline
in place, plus a tooltip with the exact score. Underline, not a highlight fill:
the reader has to be able to read the glyphs to judge the guess. A region
counts as flagged when any word is low OR its own confidence is; the header
shows "N to check" and each press walks to the next flagged region, cycling
back to the first — that button is the whole proofreading loop.
- Geometry is defensive. Boxes are clamped back inside the picture (negative
origins, oversized rectangles and NaN included) and a degenerate box is not
drawn at all. Boxes appear only once the page ratio is known — from the
payload's `aspectRatio` before load, from the image's natural size after — and
disappear entirely if the image fails: geometry with nothing to align to sits
on the wrong pixel, which is worse than no geometry.
- The frame adopts the page's own ratio, and a ResizeObserver plus an
object-contain correction converts percent-of-image into percent-of-frame. That
correction is the identity in the normal case and earns its keep the moment a
consumer caps the height: letterboxing appears and every box would otherwise
slide off the word it describes, quietly.
- The image is remounted per page id, so no load state, natural size or measured
geometry leaks into the next page. A page change also drops the selection, the
hover and the copy feedback — adjusted during render, not in an effect, so
there is never a frame showing the new page with the old page's highlight.
- A controlled `selectedRegionId` that is not on the current page selects
NOTHING rather than half-highlighting; deep-link by setting page and selection
together. A controlled `page` out of range is clamped, so a document that loses
pages cannot leave the viewer pointing at nothing.
- Copy writes the regions joined by newlines — the current page, or every page
separated by a blank line when copyScope="document". The button states its
scope ("Copy page" / "Copy all 3 pages"), flips to Copied or Copy failed for
two seconds, and hides itself when there is nothing to copy. A blocked
clipboard is reported, never a silent no-op.
- Zero regions on a page is a legitimate state (a separator sheet): the scan and
the pager stay, the pane says no text was recognised on this page, and no empty
listbox is rendered.
- Timers and observers are cleaned up on unmount, and the copy feedback checks an
alive ref before touching state after an await.
Rendering & styling
- Semantic tokens only: bg-card / border / text-muted-foreground for chrome,
bg-muted/30 for the scan column, border-primary + bg-primary/15 for an active
confident box, border-destructive + bg-destructive/15 and
decoration-destructive for anything flagged, ring-ring for the selected box,
bg-primary + text-primary-foreground for the ordinal chip. No hard-coded
colours anywhere; the destructive ordinal uses text-background because this
theme has no destructive-foreground token.
- Layout is container-driven, not breakpoint-driven: a single
repeat(auto-fit, minmax(min(17rem, 100%), 1fr)) grid puts the panes side by
side when there is room and stacks them when there is not, so the component
behaves the same in a sidebar and on a review screen.
- The text pane grows with its content instead of scrolling inside a fixed box:
a hidden line is a line nobody proofreads.
- Accessibility: root role="group" with a label, one persistent sr-only
role="status" carrying page and flag counts (selection is NOT announced there —
the focused row already says it), an aria-label per row spelling out the text,
the confidence and the uncertain words by name (the tooltip is pointer-only, so
that label is how a screen-reader user gets the same information), decorative
row internals aria-hidden to avoid double reading, focus-visible rings, and
aria-disabled (never the native `disabled`) on the pager so reaching the last
page cannot drop focus to <body>.
- Every transition is motion-reduce guarded and the reveal scroll switches to
"auto" under prefers-reduced-motion — the jump still happens, it just doesn't
animate.
- cn() merges the consumer's className into the root, which also spreads the
remaining div props and forwards its ref.
Customization levers
- Threshold: `lowConfidenceThreshold` is the whole review policy in one number —
0.8 for a clean print run, 0.95+ for handwriting or anything you sign for.
- Density: drop the per-row confidence figure with showConfidence={false}, or
reword it through `formatConfidence` (a letter grade, a star count, a raw
score). Drop the hint line and the "N to check" button for a read-only viewer.
- Copy: `copyScope` picks page or document; swap the join in the text builders if
your downstream wants tabs, JSON or one line per region.
- Wiring: `page` + `onPageChange` and `selectedRegionId` + `onRegionSelect` let a
form field, a validation error or a URL own the viewer. Leave them off and it
runs itself.
- Chrome: `label` names the region, `page.label` names the page in the header and
in the announcement, `emptyState` replaces the empty body, `errorMessage` +
`onRetry` own the job failure.
- Geometry: everything is percent-based, so a thumbnail rail, a zoom layer or a
side-by-side diff can reuse the same payload untouched — add a scale factor to
the frame, never to the boxes.Concepts
- Two-way sync is one shared id, not two effects — hover and selection are two plain ids read by both panes, so "line lights up the box" and "box lights up the line" are the same render path in opposite directions. Effects that push state from one pane into the other end up chasing each other the first time a payload updates mid-hover.
- Percent-space geometry — boxes are stored as a share of the image, never in pixels, so one payload aligns on a thumbnail and on a 4 K review screen. The frame adopts the page's own ratio and an object-contain correction (measured with a ResizeObserver) converts image-space into frame-space, which is what keeps a box on its word after someone caps the height.
- Selection follows focus, on one keyboard path — the text pane is a listbox with a roving tab order, so the whole document is a single tab stop and arrowing through it moves the highlight on the scan. The boxes stay pointer-only and aria-hidden: the same region must never be two tab stops away from itself, and duplicating the list in the tab order is how an overlay becomes unusable with a keyboard.
- Word-level uncertainty, in place — a line that scores 93 % can hide a single 31 % word, and that word is the only thing worth a human's attention. It gets a dotted underline where it sits in the sentence plus a tooltip with the score, while the row's aria-label names the uncertain words for readers who never see a tooltip.
- Reveal without hijack — activating a box focuses the matching row and scrolls with
block: "nearest", so a row already on screen does not move at all; hovering scrolls nothing. Underprefers-reduced-motionthe jump is instant instead of animated — the function survives, the animation doesn't. - The page is a reset boundary — region ids are page-scoped, so a page change drops the selection, the hover and the copy feedback and remounts the canvas. A controlled selection pointing at another page's region resolves to no selection rather than a highlight nobody can see.
Detection Overlay
Object-detection results drawn over the picture they came from — per-class tinted boxes with label and confidence chips, a live confidence gate, a class legend, and a rail whose hover, focus and selection stay in sync with the boxes.
Upgrade Prompt
An in-feature paywall — usage note, benefit bullets, a two-tier mini compare, an async upgrade CTA and a "remind me tomorrow" snooze that round-trips through the host, as a banner or as an overlay that blurs only its own box.