File Preview
A per-file preview card — image/video thumbnails, a working audio play bar, type icons with metadata, card/row/tile variants and leak-free object URLs.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/file-preview.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "FilePreview" component (lucide-react
icons, no other dependency). It previews ONE file. It never opens a file
picker, never uploads anything and never owns a list.
Contract
- export interface FileRecord { name: string; size: number (bytes, 0 is legal);
type?: string (MIME, may be missing or ""); url?: string }
- export type FilePreviewSource = File | FileRecord ← both must work: the File
a user just picked, and the record an API returned. `File` is structurally a
`FileRecord`, so read name/size/type/url off one normalized shape and use a
runtime `instanceof Blob` check (SSR-safe, unlike `instanceof File`) to decide
whether an object URL is needed.
- export type FilePreviewVariant = "card" | "row" | "tile"
- export type FileKind = "image" | "video" | "audio" | "document" |
"spreadsheet" | "archive" | "code" | "generic"
- export const FilePreview = forwardRef<HTMLDivElement, FilePreviewProps> where
FilePreviewProps extends HTMLAttributes<HTMLDivElement> and adds:
file: FilePreviewSource; variant?: FilePreviewVariant (default "card");
progress?: number; onRemove?: () => void; onDownload?: () => void;
onOpen?: () => void. Spread the rest on the root, merge className with cn().
- Also export the two helpers the card is built on: fileKindOf(name, type) and
formatFileSize(bytes).
Behavior
- KIND DETECTION IS MIME FIRST, EXTENSION SECOND, generic last: image/ video/
audio/ prefixes win; then the application/* soup (pdf + word + presentation →
document, sheet/excel/csv → spreadsheet, zip/compressed/tar/gzip → archive,
json/javascript/xml → code); then extension sets; then text/* → document. A
file with neither MIME nor extension still gets the generic icon and the label
"File" — never an empty box. Only 1-5 alphanumeric characters after the last
dot count as an extension, so "backup.2026-03-11" is not type "2026-03-11",
and a leading dot (".env") is a hidden file, not an extension.
- Media area per kind: image → <img> thumbnail; video → muted playsInline
preload="metadata" <video> plus a duration chip once metadata lands; audio →
the type icon in the box and a working play bar in the body; everything else →
the type icon. The type icon is ALWAYS rendered underneath the media element,
which fades in on top (opacity 0 → 100). That single trick covers "still
loading", "lazy, not in view yet" and "failed" with no extra branch.
- A picked File is previewed through URL.createObjectURL. CREATE IT IN THE REF
CALLBACK AND REVOKE IT IN THAT CALLBACK'S CLEANUP, so creation and revocation
are paired one-to-one: React runs the cleanup on unmount AND before
re-attaching when the file changes, so the old URL is always revoked before a
new one exists, and StrictMode's mount→unmount→mount cannot leak one. Creating
it in a lazy useState initializer leaks one URL per double mount; creating it
during render leaks one per re-render; putting it in an effect that also
setStates the URL is both an extra render and an eslint violation. For a
<video> append "#t=0.1" to the src so the browser decodes a first frame
instead of leaving the box blank — revoke the bare URL, not the fragment one.
Media elements are paused in the cleanup before the URL goes.
- A thumbnail that fails falls back to the type icon. Handle BOTH paths: the
onError event, and — for prerendered pages where a cached or data-URI image
finishes before hydration attaches the handler, so no event ever fires — a
`node.complete && node.naturalWidth === 0` probe inside the ref callback. The
same probe reads naturalWidth/naturalHeight for the metadata line.
- Media state (loaded / failed / dimensions / duration) is keyed by the source
(blob name+size+lastModified, else url) and reset DURING RENDER by comparing
that key, never in an effect. The state updater is pure so StrictMode's
double-invoke is harmless.
- Audio play bar: one real <audio> element drives every piece of state through
its own events (play/pause/timeupdate/loadedmetadata/durationchange/ended/
error), so the button can never claim to be playing something the browser
refused — a rejected play() promise flips to "Audio preview unavailable".
The track is a READ-ONLY role="progressbar" with aria-valuetext "0:12 of
2:41"; scrubbing needs slider semantics and keyboard stepping and belongs in
a player, not a preview card.
- Sizes: Intl.NumberFormat("en-US") (explicit locale, or server and client group
digits differently), BINARY steps — 1024 — with the familiar B/KB/MB/GB/TB
labels, whole bytes and one decimal below 10 from KB up ("1.4 MB", "18 MB").
Non-finite or negative sizes read "0 B".
- Metadata line: "<EXT> <noun> · <size>" — "PDF document", "MP4 video", "TS
source", "ZIP archive", plus "1600 × 900" for images once decoded.
- Long names truncate IN THE MIDDLE, both ends survive, with no measuring:
split into a shrinkable head and a protected tail (extension + ~4 chars),
render head in a `truncate` span and tail in a `shrink-0 whitespace-pre` span
inside a flex row; CSS decides when the ellipsis appears, so it is correct at
every width. Names under ~12 characters are never split. Full name in title=.
- progress (0-100, clamped, non-finite → 0) below 100 = transfer state: a
progressbar plus the percentage replaces the play bar, the remove button
becomes "Cancel upload of <name>" (same callback — the consumer aborts the
transfer), the download button is hidden and the name is NOT a link, because
there is nothing to open yet. aria-busy on the root while that lasts.
- ACTIONS ARE CALLBACK-GATED: no onRemove, no remove button; no onDownload, no
download button. onOpen deliberately adds NO button — it turns the file name
into the trigger and gives it a stretched hit area (after:absolute
after:inset-0 on a relative root), so one interactive element owns the whole
card and there is never an <a> wrapping a <button>. With no onOpen but a url,
the name becomes a real link to that url (target=_blank rel=noreferrer). The
action cluster and the play bar sit on relative z-10 above the stretched
overlay so they stay their own click targets.
- Variants share one skeleton: row = [media 44px][text][actions] on one line;
card = 16:10 media on top, then [text][actions]; tile = square media on top,
then a denser [text][actions]. data-kind and data-variant land on the root for
consumer styling.
Rendering & styling
- Semantic tokens only: bg-card/text-card-foreground root with border and
rounded-xl, bg-muted media box and progress tracks, bg-primary fills,
text-muted-foreground for metadata, hover:bg-accent
hover:text-accent-foreground for icon buttons, bg-background/85 for the
duration chip. No hex/oklch anywhere, no chart tokens on text.
- Thumbnails cross-fade with transition-opacity duration-300 and progress with
transition-[width]; both carry motion-reduce:transition-none, and nothing
depends on the animation to become usable.
- Icon buttons get aria-label + title naming the file ("Download invoice.pdf"),
size-7 hit boxes and focus-visible:ring-2 ring-ring; the thumbnail's alt is
the file name; every decorative icon is aria-hidden.
Customization levers
- Density: row padding (p-2.5 / p-3), the media sizes (size-11, aspect-[16/10],
aspect-square) and the size-7 action buttons are the three knobs; a compact
row reads fine at size-9 + size-6.
- Add a variant by adding one entry to MEDIA_CLASS / MEDIA_ICON_CLASS — the
skeleton is shared, only the media box and the padding differ.
- Kinds: KIND_ICON and KIND_NOUN are plain maps — add "font" or "3d" with its
own icon and extension set without touching the detection order.
- Byte units: flip DIVISOR to 1000 for the SI/macOS reading, or relabel to
KiB/MiB; TAIL_CONTEXT and MIN_SPLIT_LENGTH tune how much of a long name's
tail survives.
- Whole-card click: drop the after:absolute stretched overlay if you want only
the name itself to be clickable (better text selection, smaller hit area).
- Audio: swap the read-only progressbar for a range input if the card should
scrub, or delete the play bar entirely and let audio fall back to its icon.
- Metadata: the line is a plain array joined with " · " — add a modified date, an
owner or a checksum without touching layout.
- React 18 target: ref-callback cleanup landed in React 19. On 18, create the
object URL in a lazy useState initializer and revoke it in an unmount effect,
accepting one leaked URL per StrictMode double mount.Concepts
- MIME first, extension second, generic last — the type is read from
typewhen the server or the browser provides one, from the last 1–5 alphanumeric characters after the final dot when it does not, and a file with neither still gets an icon and a "File" label instead of a blank tile. - Binary sizes with familiar labels —
formatFileSizedivides by 1024 (not 1000) and still prints B / KB / MB / GB, the convention Windows and most upload UIs use, through anIntl.NumberFormat("en-US")with an explicit locale so server and client never disagree;DIVISORis one constant away from the SI reading. - Object-URL lifetime anchored to the DOM node — the URL is created in the media element's ref callback and revoked in that callback's cleanup, which React runs on unmount and before re-attaching with a different file, so every
createObjectURLis paired with exactly onerevokeObjectURLeven through StrictMode's double mount. - Icon underneath, media on top — the type icon is always rendered and the thumbnail fades in above it, so "loading", "lazy", "decode failed" and "no URL at all" collapse into one visual fallback instead of four branches (and a cached image that finished before hydration is caught by a
complete && naturalWidth === 0probe, since its events never fire). - Middle truncation without measuring — the name splits into a shrinkable head and a protected tail (extension plus a few characters); CSS
truncatedecides when the ellipsis appears, so…-auditor-notes-final.tar.gzstays legible in a 140 px tile and in a full-width row alike. - Stretched link, single interactive —
onOpenadds no button: it makes the file name the trigger and stretches its hit area over the card withafter:inset-0, so the whole card is clickable without ever nesting an<a>and a<button>; the action cluster and the play bar sit onz-10above that overlay. - Transfer state, not a queue — a
progressbelow 100 turns the card into its uploading form (bar, cancel, no open target); orchestrating many of those — concurrency, retry, pause — is the queue's job, not this card's.
Audio Visualizer
A canvas spectrum, waveform, ring or level meter driven by a real AnalyserNode — you own the mic, it only reads it.
PDF Viewer
A document viewer shell with a pluggable render layer — controlled paging synced to scroll, fit-width/fit-page zoom that re-fits on resize, a thumbnail rail, text search, keyboard and pinch, and named loading / 404 / damaged / encrypted states.
