Skip to content
Component installs need the registry setup
VegaStack Design

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
stable
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-viewer

The 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

BehaviourWhere it lives
Which file is open, and closingHost — index is controlled; onOpenChange(false) means set it to null
Signing or resolving URLsHost — pass src, srcSet, pdfSrc and downloadHref ready to use
Video and audio playbackVideoPlayer / AudioPlayer
The tiles that open itAttachment, a thumbnail grid, any button

Anatomy

FileViewer — 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"
FileViewerPdf — data-slot="file-viewer-pdf" | "file-viewer-pdf-page" | "file-viewer-pdf-toolbar"

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

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.

PDF

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>
landscape.svgImage
portrait-ada.svgImage
portrait-linus.svgImage
nova-pendant-spec-sheet.pdfPDF
quote-2026-104.xlsxSpreadsheet

API Reference

PropTypeDefaultDescription
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

AttributeValues
data-kindmirrors 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

FieldTypeNotes
idstringStable id; zoom and scroll reset when it changes.
namestringThe dialog's title and the image's alt text.
contentTypestring | nullimage/* → image, application/pdf → PDF, anything else → file card.
sizenumber | nullBytes, shown on the file card.
thumb{ src: string; srcSet?: string; blur?: string | null } | nullThe blurred placeholder, and a PDF's loading frame.
srcstring | nullFull-size image; falls back to thumb.src.
srcSetstring | nullFull-size srcset; falls back to thumb.srcSet.
pdfSrcstring | nullInline-disposition, range-capable PDF URL.
downloadHrefstringThe 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.
KeyAction
← / →Previous / next file.
+ / −Zoom the image or PDF in / out.
0Back to fit (fit width for a PDF).
EscClose and return focus.
TabMove between the viewer's controls.
ContractStates tested
Behaviourdefault, open, closed, image, pdf, other, zoomed, loading, error, single
Accessibilitynative-or-base-ui-semantics, browser-accessibility-test, keyboard-navigation, labeled, status-announcement, focus-return
Visualdefault, open, closed, hover, focus, loading

Do / Don't

Do
Open it from the tiles or thumbnails of stored files, handing it every file in the set so the user can page through them.
Don't
Use it to play video or audio, or as a general-purpose modal — it is a viewer for files the host has already resolved to URLs.

On this page