File Viewer
A full-screen dark overlay for stored files — zoomable images, pdf.js pages, and a download card for everything else — paged with arrows, buttons or a swipe.
- Status
- Since
0.23.61- Accessibility pattern
- named dialog, announced paging, focus returns
Last updated
Install
Add File Viewer from the VegaStack registry. The CLI verifies the item's integrity hash before writing it.
pnpm dlx shadcn@latest add @vegastack/file-viewerThe same command installs the registry items it composes: @vegastack/button, @vegastack/dialog, @vegastack/image, @vegastack/spinner, @vegastack/use-announcer, @vegastack/use-modal-inert.
It also adds the sanctioned engine to your package.json: pdfjs-dist.
The item installs pdfjs-dist (^6.3.289). It is imported only when a PDF is shown, so an app
that never passes pdfSrc never downloads pdf.js. The worker is served from your own origin: the
PDF part creates it with new Worker(new URL("pdfjs-dist/build/pdf.worker.min.mjs", import.meta.url), { type: "module" }),
which Next.js and Vite emit as a same-origin asset.
Usage
import { FileViewer, type FileViewerItem } from "@/components/ui/file-viewer";
const [index, setIndex] = React.useState<number | null>(null);
<FileViewer
items={files}
index={index}
onIndexChange={setIndex}
onOpenChange={(open) => !open && setIndex(null)}
/>;Each item carries resolved URLs — the viewer fetches nothing it is not handed:
const item: FileViewerItem = {
id: file.id,
name: file.name,
contentType: file.contentType,
size: file.size,
thumb: { src: file.url480, srcSet: file.srcset, blur: file.blur },
src: file.url1920, // full-size image; falls back to thumb.src
srcSet: file.srcsetFull, // falls back to thumb.srcSet
pdfSrc: file.inlineUrl, // PDFs: inline disposition, range requests
downloadHref: file.downloadUrl,
};Scope
| Behaviour | Where it lives |
|---|---|
| Which file is open, and closing | Host — index is controlled; onOpenChange(false) means set it to null |
| Signing or resolving URLs | Host — pass src, srcSet, pdfSrc and downloadHref ready to use |
| Video and audio playback | VideoPlayer / AudioPlayer |
| The tiles that open it | Attachment, a thumbnail grid, any button |
Anatomy
FileViewerPdf is the lazily loaded PDF stage FileViewer renders for a PDF item; it ships in
the same item and is not used on its own.
Examples
Image gallery
Images open fit to the screen. The item's thumb.blur (or the thumb itself) fills the image's
box, blurred, and the full image fades in over it; srcSet with sizes="100vw" lets the browser
pick the largest variant the screen needs, and the neighbouring images are preloaded so paging is
instant. Double-click or double-tap zooms to 2.5× at the pointer, a pinch or ⌘/Ctrl + scroll
zooms freely, a drag pans while zoomed, and + / − / 0 zoom in,
out and back to fit. ←/→ or the side buttons page (the buttons show on a mouse or trackpad); on a
phone a swipe left or right pages and a swipe down closes.
A PDF with a pdfSrc renders with pdf.js, loaded the first time a PDF is shown. It loads with
range requests (64 KB chunks, no background fetch), so a large file opens at page 1 without
downloading the rest. Pages render into canvases at the screen's pixel ratio, fit to the width
up to a reader's page width (920px) — on a wide screen the page sits centred on the dark stage,
on a phone it fills the width — in a vertical scroller; only the current page and two either
side hold a canvas. The toolbar zooms out, in and back to fit width, and shows "Page 2 of 3". While the document loads, the
item's thumb (a PDF's preview image) or a spinner shows; if it cannot load, the file card below
takes its place.
Other files
Anything that is not an image or a PDF — or an image or PDF with no URL to show — gets a card: the file-type icon, the name, the size ("2.4 MB") and a Download button.
Single file
With one item there is no "1 of 1" and no paging buttons.
From attachment tiles
AttachmentTrigger renders a <button> that covers the tile, so an onClick that sets the index
is all it takes — no render link. Focus returns to the tile that opened the viewer when it
closes.
<AttachmentGroup>
{files.map((file, i) => (
<Attachment key={file.id} orientation="vertical">
<AttachmentMedia variant="image">
<img src={file.thumb?.src} alt="" />
</AttachmentMedia>
<AttachmentContent>
<AttachmentTitle>{file.name}</AttachmentTitle>
</AttachmentContent>
<AttachmentTrigger
aria-label={`Open ${file.name}`}
onClick={() => setIndex(i)}
/>
</Attachment>
))}
</AttachmentGroup>API Reference
| Prop | Type | Default | Description |
|---|---|---|---|
index* | number | null | — | The open file's index; null closes the viewer. |
items* | readonly FileViewerItem[] | — | The files to page through, in order. |
onIndexChange* | (index: number) => void | — | Called with the next index when the user pages (arrows, buttons, swipe). |
onOpenChange* | (open: boolean) => void | — | Called with false when the user closes it (Esc, ×, swipe down) — set index to null. |
Data attributes and CSS variables on FileViewer
| Attribute | Values |
|---|---|
data-kind | mirrors a prop or state value |
data-slot | "file-viewer" | "file-viewer-backdrop" | "file-viewer-close" | "file-viewer-count" | "file-viewer-download" | "file-viewer-header" | "file-viewer-stage" | "file-viewer-title" |
FileViewerItem
| Field | Type | Notes |
|---|---|---|
id | string | Stable id; zoom and scroll reset when it changes. |
name | string | The dialog's title and the image's alt text. |
contentType | string | null | image/* → image, application/pdf → PDF, anything else → file card. |
size | number | null | Bytes, shown on the file card. |
thumb | { src: string; srcSet?: string; blur?: string | null } | null | The blurred placeholder, and a PDF's loading frame. |
src | string | null | Full-size image; falls back to thumb.src. |
srcSet | string | null | Full-size srcset; falls back to thumb.srcSet. |
pdfSrc | string | null | Inline-disposition, range-capable PDF URL. |
downloadHref | string | The Download link's URL. |
Accessibility
- A modal dialog named by the file name; everything behind it is inert. Focus moves to the viewer itself when it opens (no control looks pressed; Tab reaches them) and returns to the element that opened it when it closes.
- Paging announces the destination through a polite live region — "Image 3 of 12", "PDF 4 of 12", "File 5 of 12". Opening does not announce: the dialog's name already says which file.
- The icon buttons are named (
Download {name}, "Close", "Previous file", "Next file", "Zoom in", "Zoom out", "Fit to width"); on a touch screen they grow to 44px. - Each rendered PDF page is an image named
{name}, page N. - Motion (the zoom easing, the fade) collapses under reduced motion.
| Key | Action |
|---|---|
| ← / → | Previous / next file. |
| + / − | Zoom the image or PDF in / out. |
| 0 | Back to fit (fit width for a PDF). |
| Esc | Close and return focus. |
| Tab | Move between the viewer's controls. |
| Contract | States tested |
|---|---|
| Behaviour | default, open, closed, image, pdf, other, zoomed, loading, error, single |
| Accessibility | native-or-base-ui-semantics, browser-accessibility-test, keyboard-navigation, labeled, status-announcement, focus-return |
| Visual | default, open, closed, hover, focus, loading |