AI

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import {
  AlertCircle,
  Check,
  ChevronLeft,
  ChevronRight,
  Copy,
  CopyX,
  FileSearch,
  ImageOff,
  RefreshCcw,
  ScanText,

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/ocr-viewer.json

Prompt

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. Under prefers-reduced-motion the 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.

On This Page