Media

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import {
  Download,
  File as FileIcon,
  FileArchive,
  FileCode,
  FileImage,
  FileMusic,
  FilePlay,
  FileSpreadsheet,
  FileText,
  Pause,

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/file-preview.json

Prompt

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 type when 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 labelsformatFileSize divides by 1024 (not 1000) and still prints B / KB / MB / GB, the convention Windows and most upload UIs use, through an Intl.NumberFormat("en-US") with an explicit locale so server and client never disagree; DIVISOR is 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 createObjectURL is paired with exactly one revokeObjectURL even 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 === 0 probe, 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 truncate decides when the ellipsis appears, so …-auditor-notes-final.tar.gz stays legible in a 140 px tile and in a full-width row alike.
  • Stretched link, single interactiveonOpen adds no button: it makes the file name the trigger and stretches its hit area over the card with after:inset-0, so the whole card is clickable without ever nesting an <a> and a <button>; the action cluster and the play bar sit on z-10 above that overlay.
  • Transfer state, not a queue — a progress below 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.

On This Page