Skip to main content

FileViewer

Viewerdisplay

Render a file the app did not write (upload, signed URL, agent output) — detects the format, loads the matching adapter on demand, draws it with brand-ui components.

import { FileViewer } from "@elabs-ai/components-viewer";
Install
npm install @elabs-ai/components-tokens @elabs-ai/components-ui @elabs-ai/components-viewer

Skipping the @source line is the most common first failure: Tailwind does not scan node_modules, so every component renders unstyled. The one-time wiring

Prompt for your coding agent

Paste it into Claude Code, Cursor, Codex or any other coding agent. Text in [brackets] is yours to fill in.

Add the brand-ui FileViewer component (from @elabs-ai/components-viewer) to this project.

1. Read https://elabs-ai.com/llms.txt first: it maps every package and the rules. If you can use MCP servers, connect https://elabs-ai.com/mcp and use its search, docs and tokens tools. If not, run the same lookups in the terminal: `npx -y @elabs-ai/components-cli search "<what you need>"` and `npx -y @elabs-ai/components-cli docs <Component>`.
2. Look FileViewer up before writing code: `npx -y @elabs-ai/components-cli docs FileViewer` prints its real props, variants, what it pairs with and what to avoid.
3. If @elabs-ai/components-viewer is not installed yet: `pnpm add @elabs-ai/components-tokens @elabs-ai/components-ui @elabs-ai/components-viewer` (or `npm install @elabs-ai/components-tokens @elabs-ai/components-ui @elabs-ai/components-viewer`). If this is the first brand-ui package in the project, wire it once: import `@elabs-ai/components-tokens/styles.css` plus the `themes/light.css` and `themes/dark.css` stylesheets in the CSS entry, add one Tailwind `@source` line per installed @elabs-ai package (Tailwind does not scan node_modules, so without it everything renders unstyled), and wrap the app root in `ThemeProvider` from `@elabs-ai/components-tokens`.
4. Use it here: [the screen or file, and what it should show]
5. Rules: use only @elabs-ai/components-* components and the semantic tokens — no raw colours, no raw font sizes, no arbitrary Tailwind values. Look up each component's real props with the docs tool before you use it; never guess a prop.
6. Done means: the typecheck passes, `npx -y @elabs-ai/components-cli audit src --strict` reports nothing, and you have opened the app and looked at every screen you touched in the light and the dark theme.

Avoid

  • Adding a per-format branch to FileViewerContent — a format is an adapter registration (registry.register), never a switch in the shell.
  • Making a parser a plain dependency — every parser engine is an OPTIONAL peer, so a consumer who skips it gets the panel naming the package instead of a build error.
  • Returning HTML from an adapter — adapters emit a DATA model that brand-ui components render; that is the only reason an arbitrary file inherits the theme.
  • Using FileViewer to edit — it is read-only; authoring code is CodeEditor (@elabs-ai/components-editor).
  • Clearing an error with the `loading` prop — a parent's loading is additive; it can add the not-ready state but never hide a real failure.

Works with

Contains

  • FileViewerToolbar
  • FileViewerContent

Pairs with

  • FileViewerProvider
  • FileViewerSkeleton
  • FileViewerError
  • FileViewerEmpty

Examples

32 live examples

CSV

A real CSV parser, so a quoted comma stays in one cell.

Plain Text

Markdown

A markdown file read as a DOCUMENT — real headings, lists and links drawn with the library's own prose primitives, so a README in the viewer looks like the same README in a chat answer or the editor's preview. `streamdown` is an optional peer. No Streamdown plugins are installed, which is a deliberate trade: fenced code renders as an unhighlighted block rather than costing a consumer four more packages to open one file. A source file opened directly still gets Shiki — see `Source code` below. Pick a markdown renderer by where the markdown is going to be READ: a file the app did not write, opened here → this adapter; a read-only document in a chat or a side rail → `AI/MarkdownView`; the preview pane of the markdown editor → `Editor/MarkdownPreview`; streaming into a message as the model writes it → `MessageResponse` on `AI/Message`. See [Choosing between similar components](?path=/docs/docs-choosing-between-similar-components--docs). The element map here is deliberately a near-copy of `MarkdownView`'s, not a shared module: `@elabs-ai/components-ai`, `@elabs-ai/components-editor` and `@elabs-ai/components-viewer` are leaves that may not import one another, and the half that could move down (the `Prose*` primitives, the Streamdown locale bridge) already has. What differs is the job — a file arrives settled or not at all, so this adapter takes no plugins and does not stream.

Source code

A source file, tokenized by Shiki at load and coloured entirely with `var(--code-*)` references — so switching the theme recolours the document without re-highlighting it, and no raw hex ever enters the model. `shiki` is an optional peer, and this adapter claims only extensions it has a grammar for. A `.log`, a `.env` or an unknown extension keeps falling through to the plain-text backstop rather than failing to open.

Image

PDF

A real PDF, opened by pdf.js on a worker: the page is rasterized in the file's own colours, and a transparent text layer over it stays selectable. The pages STACK and the pane scrolls, the way every document reader works — page 2 is a gesture away, not behind a button. Only the pages near the viewport are mounted, so the cost follows the screen rather than the page count. The pager, the scale and the rotate button are the SHELL's (ADR 0026), not the adapter's — which is why the same controls drive the deck below, and why an app can move any of them into its own header. `pdfjs-dist` is an optional peer — a consumer who never opens a PDF never installs it, and one who forgot sees the `ParserMissing` panel below.

Audio

Video and audio render through ui `Audio` / `Video` (ADR 0041): a token-styled control bar — play, seek, time, volume — that follows the theme, with keyboard shortcuts on the player, in place of the browser's native chrome. The adapter streams from a URL and never buffers the bytes, so a 2 GB recording seeks instantly. The fixture is audio because a real, playable video cannot be synthesized inline; the video player shares the same parts, with a viewport above the bar.

Media (undecodable)

The settled "can't play this" outcome — a codec the browser does not ship, or a truncated file. It is an error, not a capability gap: the format IS supported, this particular file just will not decode. No retry is offered, because retrying cannot install a codec.

Word

A real Word file. mammoth resolves Word's styles, numbering and images; the adapter then parses that into a block model and draws it with the library's own prose primitives — so a `.docx` inherits the theme, the type scale and real heading semantics instead of arriving as a slab of foreign markup. It shows the document's STRUCTURE, not Word's page layout: no page breaks, no columns, no margins. The toolbar's download is the answer for the real thing.

Excel

A real workbook, every sheet reachable. SheetJS is asked only for the DATA — never its `sheet_to_html` helper — so the grid is the same `Table` the CSV adapter renders, with the theme, the density dial and keyboard semantics. `xlsx` is an optional peer with two npm-only advisories; see `docs/CONSUMING.md` before installing it in a build that opens untrusted files.

Power Point

A real deck, read as an outline: title, text at its authored indent level, and the speaker notes — which are followed through the slide's own relationship, not by matching slide numbers. Slides stack and scroll, like the PDF above: a deck is skimmed by scrolling through it, not by clicking "next" once per slide. There is no PowerPoint library here; a `.pptx` is a zip of XML, so jszip opens it and the platform's `DOMParser` reads it. Positional design (layout, images, charts, transitions) is deliberately absent rather than half-reproduced.

Empty

No file chosen yet — an invitation, not a failure.

Loading

The layout-shaped skeleton. Held open by an adapter that never resolves, so the state can be inspected rather than glimpsed.

Unsupported Format

No adapter claims the file. Not retryable — retrying changes nothing.

Parser Missing

The optional parser package is not installed. The message names it, because "couldn't open this file" would leave the reader with nothing to do.

Read Failed

A settled read failure — the one error state that offers a retry.

Parent Loading

A parent that fetches the file itself can hold the viewer not-ready.

quarterly-report.pdf

The parts, arranged by hand. `FileViewerProvider` is the state boundary, so a control **outside** the frame but inside the provider reads the same state the viewer does — the reason this is a compound component rather than one prop bag.

Page And Zoom

The same file opened at page 2, at a fixed 125%. Page and scale are the SHELL's state, so they are ordinary props with the controlled/uncontrolled trio — which is what makes a deep link ("open the contract at page 2") a one-liner instead of a reach inside the canvas. There is no rotate button here, and that is the point: the PDF manifest does not claim `rotate`, so the control does not exist. A capability is a promise the renderer keeps — chrome for one it has not implemented would be a lie the reader can click.

Rotated

Rotation, on the format that implements it. `image` DOES claim `rotate`, so the button appears and turns the picture a quarter at a time. It also honours the fit modes and the fixed stops, which the manifest had been claiming for a renderer that did neither.

Chrome In The App Header

The controls moved OUT of the viewer, into the app's own header. They are parts over the provider, not adapter chrome: anything inside `FileViewerProvider` reads and drives the same page and scale, so a workspace can put the pager beside a breadcrumb and keep the document pane clean. This is the composition the old design could not express at all — page and zoom were `useState` inside the PDF renderer, so there could only ever be one pager, and only inside the canvas.

Continuous Scroll

The other direction: the reader scrolls, and the chrome follows. A paginated document is a COLUMN, not a flipbook — page 2 sits one gesture below page 1, and only the pages near the viewport are ever mounted, so a 900-page file costs what is on screen rather than what is in the file. The page number is a two-way binding: type "2" and the column scrolls, scroll and the field updates.

Citations

A chat answer cites three passages; clicking one scrolls the document to it and marks it. The app owns the citation list and which one is current — the viewer only locates and paints them, so the same list drives a sidebar, a stepper, or a chat bubble without any of them talking to each other. The current passage is never signalled by colour alone: it also carries an outline and `aria-current="true"` (WCAG 1.4.1).

Passage Not Found

A quote that is not in the document is a STATE, not a no-op. The reader clicked a source link, so silence would leave them unable to tell whether the viewer broke, the passage moved, or they mis-clicked.

Find In Document

Find-in-document — press **Ctrl/Cmd + F** with the viewer focused. It paints through the same mark layer citations use, so a document never grows two highlight systems that disagree about what a mark looks like. The shortcut is bound to the FRAME, not `document`: a page may hold several viewers, and the browser's own find is untouched everywhere else.

PDF citations

A PDF page is pixels, so a citation is a BOX over the raster rather than a `<mark>` — same state, same stepper, different paint. Stepping to the second passage turns to page 2 on its own: the quote was located in the text projection, and the document's index says which page that stretch came from. The current box is not distinguished by colour alone — it is also drawn twice as thick (WCAG 1.4.1).

Word citations

A Word document marks the WORDS. The passage is underlined where it falls — mid-paragraph, through a bold run, inside one bullet, in a single table cell — and the document keeps reading as a document around it. Word's own page layout is gone by the time this renders (see `Word` above), so there is no page to draw a box on: the marks are the whole answer here.

Markdown citations

Markdown is addressed by its SOURCE, which is why its citations are plated blocks rather than `<mark>`s: source offset 212 can land inside `**bold**`, two of whose characters are never drawn. So a passage lights up the paragraph, heading, list item or fence it lives in — enough to point a reader at it, and incapable of being subtly wrong about which words it covers. The current plate doubles its rail as well as warming its fill, and carries `aria-current="true"` (WCAG 1.4.1).

Spreadsheet citations

A spreadsheet marks the CELL. Rows are what the projection is indexed by, but the mark is still character-granular, so a citation lands inside one cell and leaves the rest of the row alone. Stepping to the third citation switches TAB as well as scrolling: the passage lives on Headcount, and a mark on a sheet nobody is looking at is the same as no mark at all.

Deck citations

A deck is read as an outline, so a citation marks the line it came from — a bullet, a title, a table row, or the speaker notes. Stepping to a passage on another slide PAGES there first, the same way the PDF turns to the cited page.

Find In Source Code

The same search over a source file — marks survive syntax colouring.

API

FileViewer

PropTypeDescription
childrenReactNodeReplace the default composition. Rendered inside the provider AND the frame.
sourceFileSourceThe file to show. `undefined` is the empty state, not an error.
registryViewerRegistryAdapters available to this viewer. Defaults to the built-ins. Pass your own to add a format, drop one, or override a built-in.
loadingbooleanForce the not-ready state while a parent fetches the source itself. ORed with the viewer's own loading — a parent can add loading, never remove it.
baseHeadingLevelProseHeadingLevelThe rung a viewed document's own top-level heading renders at. Default `2`. A file carries its OWN heading tree, and that tree is only correct relative to the page hosting it: a README's `#` rendered as an `<h1>` inside an app that already has one puts two `h1`s in a screen reader's flat heading list, and the frame's `<section aria-label>` does not fix that — most screen readers list headings flat, not per landmark. The default assumes the common case, a viewer embedded BELOW the page's own heading; pass `1` when the viewer genuinely is the page. Adapters that render no headings ignore it. Same seam as `@elabs-ai/components-ai`'s `MarkdownView baseHeadingLevel`.
highlightsreadonly DocumentHighlight[]The parts of the document to point at — an answer's citations, a search result's context, anything the app already knows about the file. A PROP rather than provider-only state because citations originate outside the viewer entirely: the chat pane that produced them usually lives in another route, and it owns which one the reader clicked. Pass `defaultHighlights` instead to let the viewer own them.
defaultHighlightsreadonly DocumentHighlight[]Uncontrolled initial citations. Ignored when `highlights` is supplied.
onHighlightsChange(highlights: readonly DocumentHighlight[]) => void
activeHighlightIdstring | nullWhich citation the viewer is pointed at. `null` is "none" explicitly; `undefined` is what selects uncontrolled mode, so the two are not interchangeable here.
defaultActiveHighlightIdstring | null
onActiveHighlightChange(id: string | null) => void
pageNumbernumberWhich page the viewer is on, 1-based — controlled. A trio (`component-api.md`) rather than provider-only state because the page is routinely something the APP owns: a deep link to page 7, a URL the reader can share, a position restored from a "continue reading" record. Clamped to the document on read, so an out-of-range value degrades to the nearest real page instead of blanking the canvas.
defaultPageNumbernumberUncontrolled initial page. Ignored when `pageNumber` is supplied.
onPageNumberChange(page: number) => void
zoomZoomLevelThe scale to draw at, or a fit mode — controlled. Persisting a reader's preferred zoom across files and sessions is the reason this is a prop.
defaultZoomZoomLevelUncontrolled initial zoom. Ignored when `zoom` is supplied. Defaults to `"fit-width"`, not `1`: a viewer's job on open is to show the document, and a 4000px scan or an A4 page at 100% in a 600px pane shows its top-left corner. Every reader-facing PDF viewer opens fitted for the same reason. Pass `1` for true 100%.
onZoomChange(zoom: ZoomLevel) => void
rotationDocumentRotationQuarter-turns clockwise — controlled.
defaultRotationDocumentRotationUncontrolled initial rotation. Ignored when `rotation` is supplied. Default `0`.
onRotationChange(rotation: DocumentRotation) => void

Also accepts: Omit<FileViewerProviderProps, "children">, Omit<HTMLAttributes<HTMLDivElement>, "children">

FileViewerContent

Also accepts: HTMLAttributes<HTMLDivElement>

FileViewerEmpty

Also accepts: HTMLAttributes<HTMLDivElement>

FileViewerError

Also accepts: HTMLAttributes<HTMLDivElement>

FileViewerFind

Also accepts: HTMLAttributes<HTMLDivElement>

FileViewerFrame

Also accepts: HTMLAttributes<HTMLDivElement>

FileViewerHighlightStatus

Also accepts: HTMLAttributes<HTMLDivElement>

FileViewerPager

Also accepts: HTMLAttributes<HTMLDivElement>

FileViewerProvider

PropTypeDescription
sourceFileSourceThe file to show. `undefined` is the empty state, not an error.
registryViewerRegistryAdapters available to this viewer. Defaults to the built-ins. Pass your own to add a format, drop one, or override a built-in.
loadingbooleanForce the not-ready state while a parent fetches the source itself. ORed with the viewer's own loading — a parent can add loading, never remove it.
baseHeadingLevelProseHeadingLevelThe rung a viewed document's own top-level heading renders at. Default `2`. A file carries its OWN heading tree, and that tree is only correct relative to the page hosting it: a README's `#` rendered as an `<h1>` inside an app that already has one puts two `h1`s in a screen reader's flat heading list, and the frame's `<section aria-label>` does not fix that — most screen readers list headings flat, not per landmark. The default assumes the common case, a viewer embedded BELOW the page's own heading; pass `1` when the viewer genuinely is the page. Adapters that render no headings ignore it. Same seam as `@elabs-ai/components-ai`'s `MarkdownView baseHeadingLevel`.
highlightsreadonly DocumentHighlight[]The parts of the document to point at — an answer's citations, a search result's context, anything the app already knows about the file. A PROP rather than provider-only state because citations originate outside the viewer entirely: the chat pane that produced them usually lives in another route, and it owns which one the reader clicked. Pass `defaultHighlights` instead to let the viewer own them.
defaultHighlightsreadonly DocumentHighlight[]Uncontrolled initial citations. Ignored when `highlights` is supplied.
onHighlightsChange(highlights: readonly DocumentHighlight[]) => void
activeHighlightIdstring | nullWhich citation the viewer is pointed at. `null` is "none" explicitly; `undefined` is what selects uncontrolled mode, so the two are not interchangeable here.
defaultActiveHighlightIdstring | null
onActiveHighlightChange(id: string | null) => void
pageNumbernumberWhich page the viewer is on, 1-based — controlled. A trio (`component-api.md`) rather than provider-only state because the page is routinely something the APP owns: a deep link to page 7, a URL the reader can share, a position restored from a "continue reading" record. Clamped to the document on read, so an out-of-range value degrades to the nearest real page instead of blanking the canvas.
defaultPageNumbernumberUncontrolled initial page. Ignored when `pageNumber` is supplied.
onPageNumberChange(page: number) => void
zoomZoomLevelThe scale to draw at, or a fit mode — controlled. Persisting a reader's preferred zoom across files and sessions is the reason this is a prop.
defaultZoomZoomLevelUncontrolled initial zoom. Ignored when `zoom` is supplied. Defaults to `"fit-width"`, not `1`: a viewer's job on open is to show the document, and a 4000px scan or an A4 page at 100% in a 600px pane shows its top-left corner. Every reader-facing PDF viewer opens fitted for the same reason. Pass `1` for true 100%.
onZoomChange(zoom: ZoomLevel) => void
rotationDocumentRotationQuarter-turns clockwise — controlled.
defaultRotationDocumentRotationUncontrolled initial rotation. Ignored when `rotation` is supplied. Default `0`.
onRotationChange(rotation: DocumentRotation) => void
childrenrequiredReactNode

FileViewerRotate

Also accepts: HTMLAttributes<HTMLButtonElement>

FileViewerSkeleton

Also accepts: HTMLAttributes<HTMLDivElement>

FileViewerToolbar

PropTypeDescription
actionsReactNodeExtra controls, placed after the built-in actions.

Also accepts: HTMLAttributes<HTMLDivElement>

FileViewerZoom

Also accepts: HTMLAttributes<HTMLDivElement>

Theming

The tokens each state resolves to. Change the token and every theme follows.

StateToken
framebg-card + border-border + shadow-sm — a resting raised surface, not a float
toolbar dividerborder-border-strong — the sole structural cue between toolbar and content (same fill, no elevation change), so the WCAG 1.4.11 strong rung