Media

File Icon

An extension-to-icon mark — nine file families, a four-step size scale, tints drawn from your own tokens, and the extension printed whenever the type is unknown.

Preview in your theme

Loading preview…

import * as React from "react"
import {
  File as FileGlyph,
  FileArchive,
  FileCode,
  FileImage,
  FileMusic,
  FilePlay,
  FileSpreadsheet,
  FileText,
  Presentation,
  type LucideIcon,
} from "lucide-react"
import { cn } from "@/lib/utils"

Installation

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

Prompt

The prompt behind this component — paste it into your AI assistant to recreate or adapt it.

Build a React + TypeScript + Tailwind "FileIcon" component: extension-to-icon
mapping as a real component, not a switch statement copied into every list.
lucide-react for the glyphs, nothing else. It holds no state, runs no effect and
attaches no listener, so it needs no "use client" and has nothing to clean up.

Contract
- forwardRef<HTMLSpanElement>, props extend React.HTMLAttributes<HTMLSpanElement>;
  everything not listed below spreads onto the root <span>, className merges into
  it through cn().
- Identity, three overlapping ways of naming the same file:
  name?: string (file name, path or URL), ext?: string ("pdf", ".PDF", "tar.gz"),
  type?: string (MIME). family?: FileFamily forces the answer and skips detection.
- FileFamily = "document" | "sheet" | "slide" | "image" | "audio" | "video" |
  "archive" | "code" | "unknown". Nine buckets, and `unknown` is one of them —
  it is a rendering branch, not a failure.
- size?: "sm" | "md" | "lg" | "xl" (default "md") — one table maps each step to
  the mark box, the glyph size, three text steps and the label size.
- variant?: "plain" | "tile" (default "tile") — tile draws a rounded tinted
  square, plain draws the bare glyph in the same box so rows still line up.
- tone?: "muted" | "family" (default "muted"); tint?: string overrides both with
  any CSS colour, e.g. var(--chart-3).
- extensionLabel?: "auto" | "always" | "never" (default "auto").
- icon?: React.ReactNode replaces the glyph — the escape hatch for a third-party
  brand mark (.fig, .sketch, .ai). The vendor's artwork and colour arrive from
  the consumer; the built-in map never hardcodes someone else's palette.
- alt?: string | boolean is the accessible name (see ARIA below).
- Export the detection helpers too — fileFamilyOf({name, ext, type}),
  fileExtensionOf(name), fileTypeLabel(family, ext) — so a list can group, sort
  or announce by family without rendering a mark.

Behavior
- Precedence: explicit `family` → extension (from `ext`, else parsed from
  `name`) → MIME → "unknown". Extension outranks MIME on purpose: servers hand
  out application/octet-stream for everything they do not recognise, while the
  name the user chose is usually right. A `.ts` file served as text/plain is
  still source.
- Parsing a name is where the edge cases live. Drop a query or hash first
  (names arrive as URLs), keep only the last path segment, then: no dot at all
  means no extension ("Makefile" is a name, not a type); a leading dot IS the
  type (".env" reads as env); a segment is only an extension if it is 1–10
  chars of [a-z0-9+] and not all digits, so "db-backup.2026-03-11" has no type
  and never renders "2026-03-11" as one; a "tar" stem stays glued to its tail so
  a tarball reads TAR.GZ rather than GZ, while the family lookup still uses the
  last segment.
- An `ext` prop that does not normalize falls through to the name instead of
  blanking the mark.
- MIME is matched by prefix for image/audio/video, then by substring on the
  subtype (word / opendocument.text / rtf / epub → document, sheet / excel /
  csv → sheet, presentation / powerpoint / keynote → slide, zip / compressed /
  tar / gzip / rar → archive, json / xml / javascript / yaml / sql → code).
  Order matters: check the document group before the sheet group, because the
  Office subtypes share vocabulary.
- The unknown branch is the product. When the family is unknown and the
  extension is 4 characters or fewer, the extension itself becomes the mark:
  uppercase, centred in the same box, tinted like any other family. Longer than
  that and it moves beside the glyph as a small label, because "SKETCH" cannot
  live inside a 32px square. With no extension at all, the generic page glyph
  stands alone. Nothing is ever swallowed silently.
- extensionLabel: "auto" prints the extension only when the mark cannot say what
  the file is — unknown family, and no custom `icon` (an icon is a definite
  answer, so it silences the label). "always" prints it next to every glyph.
  "never" buys a tighter row by accepting a mute glyph, and also turns off the
  text mark.
- Text is step-fitted, never measured: the font step is picked from the
  character count (1–2 / 3 / 4 chars), and the box swaps its fixed square for
  fixed height plus min-width so a wide "WEBP" grows sideways instead of
  overflowing. No ResizeObserver, no layout read — the server and the client
  render the same box, so hydration cannot disagree.
- ARIA: with no `alt`, the mark is aria-hidden. A file icon almost always sits
  next to the file name it describes, and announcing "PDF document Handbook.pdf"
  reads the type twice. alt={true} names it from what was detected ("PDF
  document", "TAR.GZ archive", "SKETCH file", "File"); alt="…" overrides that
  text; a consumer-supplied aria-label or aria-labelledby also exposes it. Any
  of those switch the root to role="img". `title` is a tooltip, not a name, and
  never exposes the mark on its own.
- Also emit data-family so a list can style or test against the detected family
  without re-running detection.

Rendering & styling
- Semantic tokens only. tone="muted" is bg-muted + text-muted-foreground for all
  nine families — a file row is chrome, not confetti. tone="family" maps each
  family to a theme variable (document var(--chart-1), sheet var(--chart-2),
  slide var(--chart-3), image var(--chart-4), video var(--chart-5), audio
  var(--primary), code var(--foreground), archive and unknown share
  var(--muted-foreground) because neither has an identity to express). The tile
  background is color-mix(in oklab, <tint> 12%, transparent) so one value drives
  both the glyph and its wash, in light and dark alike.
- The root is inline-flex, shrink-0 and align-middle so the mark sits on a text
  baseline without stretching in a flex row; the mark box is a grid with
  place-items-center; rounded-md only in the tile variant, so the host --radius
  keeps ownership of the corner.
- The only motion is transition-colors on the mark, behind
  motion-reduce:transition-none. With motion off nothing about the component
  changes but the crossfade.
- The glyph is drawn at strokeWidth 1.75 so a 14px mark stays legible; a
  supplied `icon` is wrapped in a box with [&_svg]:size-full so any artwork
  lands on the same size scale.

Customization levers
- The map is the product: FAMILY_EXTENSIONS is one editable table of family →
  extensions. Add a row and an extension gets an icon; the Map is derived from
  it at module scope, so there is no second place to update.
- Families: add one (e.g. "font", "3d", "notebook") by extending the union and
  giving it a row in FAMILY_EXTENSIONS, FAMILY_ICON, FAMILY_NOUN and
  FAMILY_TINT — four aligned tables, no branching logic.
- Colour: keep tone="muted" for quiet chrome, switch to tone="family" for a
  drive-like grid, or pass tint per instance for one-off emphasis. To match a
  brand palette, remap FAMILY_TINT to your own variables — never to a hex.
- Density: SIZES is the whole size scale in one object. Change "md" and every
  md mark in the app follows, including the text steps.
- Shape: swap rounded-md for rounded-full, or drop the tile background and keep
  variant="plain" everywhere for a text-editor feel.
- Brand marks: pass icon={<FigmaMark />} for the handful of types worth
  recognising by artwork, and leave the map to handle the long tail.
- Detection: fileFamilyOf is pure and exported — reuse it for grouping, for
  sorting a file table by type, or swap it for a server-provided family and pass
  `family` directly.

Concepts

  • Unknown is a family, not a failure — the branch that has no glyph to offer prints the extension instead, so .sketch, .fig and .blend read correctly on a machine that has never heard of them; only a name carrying no type at all falls back to the generic page.
  • Extension outranks MIME — the name a person chose is more reliable than a header a server guessed, so MIME is kept as the safety net for the one case that has no name: a pasted blob.
  • Step-fitted text — the font step comes from the character count and the box grows sideways instead of clipping, which keeps the fallback purely declarative: no measurement, no observer, and no chance of the server and the client disagreeing.
  • Tint from the host, not the vendor — every colour is a theme variable and the tile wash is mixed from the same one, so dark mode and a re-themed project come free; a third-party brand mark can only enter through the icon prop.
  • Decorative by default — the mark is aria-hidden unless you name it, because it almost always sits beside the file name it describes; alt promotes it to a named role="img" for the grid cell where it stands alone.

On This Page