File icon

A macOS-style document icon filled with media, with shared SVG defs, Finder's selected ring and pill, and middle truncation that keeps file endings readable.

Show code
import type { ReactNode } from "react";
import { cn } from "@/lib/utils";

/**
 * A macOS-style document icon (a page with a folded top-right corner) filled
 * with media, plus Finder's selected state and middle-truncated name.
 *
 * The page shape, glow blur and fold gradient live once in `FileIconDefs`;
 * every icon only references them by id, so a grid of 100 icons carries no
 * per-tile SVG defs and the media stays an ordinary lazily loaded element.
 *
 * Geometry is a 100 × 128 page: 8-unit corner radius, 30-unit dog-ear.
 */

const DOC = "M8 0H70L100 30V120Q100 128 92 128H8Q0 128 0 120V8Q0 0 8 0Z";
/** Same outline in 0–1 units for the HTML clip (clipPathUnits="objectBoundingBox"). */
const DOC_BOX = "M0.08 0H0.7L1 0.234375V0.9375Q1 1 0.92 1H0.08Q0 1 0 0.9375V0.0625Q0 0 0.08 0Z";
const FOLD = "M70 0V22Q70 30 78 30H100Z";
const FOLD_EDGE = "M70 0V22Q70 30 78 30H100";

/** Shared defs. Render once per page (hidden), before or after the icons. */
export function FileIconDefs() {
  return (
    <svg aria-hidden width="0" height="0" className="pointer-events-none absolute size-0 overflow-hidden">
      <defs>
        <clipPath id="file-doc-clip" clipPathUnits="objectBoundingBox">
          <path d={DOC_BOX} />
        </clipPath>
        <clipPath id="file-doc-clip-vb" clipPathUnits="userSpaceOnUse">
          <path d={DOC} />
        </clipPath>
        <filter id="file-doc-blur" x="-20%" y="-20%" width="140%" height="140%">
          <feGaussianBlur stdDeviation="2.5" />
        </filter>
        {/* Paper is paper in any theme: the fold stays white to light grey. */}
        <linearGradient id="file-doc-fold" x1="0" y1="1" x2="1" y2="0">
          <stop offset="0" className="text-white" stopColor="currentColor" />
          <stop offset="1" className="text-neutral-300" stopColor="currentColor" />
        </linearGradient>
      </defs>
    </svg>
  );
}

/**
 * The page: media clipped to the document shape, a white inner glow, a
 * hairline edge and the folded corner. Pass the media (an <img>, a <video>)
 * as children; it is stretched to cover the page.
 */
export function FileIcon({ children, className }: { children?: ReactNode; className?: string }) {
  return (
    <span
      className={cn(
        "relative block aspect-[100/128] w-full bg-muted [&>:not(svg)]:absolute [&>:not(svg)]:inset-0 [&>:not(svg)]:size-full [&>:not(svg)]:object-cover",
        className,
      )}
      style={{ clipPath: "url(#file-doc-clip)" }}
    >
      {children}
      <svg aria-hidden viewBox="0 0 100 128" className="pointer-events-none absolute inset-0 size-full">
        <g clipPath="url(#file-doc-clip-vb)">
          {/* White inner shadow: a wide blurred stroke on the edge, clipped to the inside. */}
          <path d={DOC} fill="none" className="stroke-white" strokeOpacity="0.55" strokeWidth="9" filter="url(#file-doc-blur)" />
          <path d={DOC} fill="none" className="stroke-white" strokeOpacity="0.35" strokeWidth="2" vectorEffect="non-scaling-stroke" />
          {/* Shadow cast by the dog-ear onto the page. */}
          <path d={FOLD_EDGE} fill="none" className="stroke-black" strokeOpacity="0.35" strokeWidth="4" filter="url(#file-doc-blur)" />
        </g>
        <path d={FOLD} fill="url(#file-doc-fold)" />
      </svg>
    </span>
  );
}

/**
 * Finder-style middle truncation: "A VERY LONG RELEASE TITLE.wav" →
 * "A VERY LONG RELE…TITLE.wav". Keeps the start and the last few characters,
 * so similar names stay distinguishable. Counts graphemes, not UTF-16 units.
 */
export function middleTruncate(text: string, max = 34, tail = 9): string {
  const chars = Array.from(text.trim());
  if (chars.length <= max) return chars.join("");
  const head = max - tail - 1;
  return `${chars.slice(0, head).join("").trimEnd()}…${chars.slice(-tail).join("").trimStart()}`;
}

/**
 * A desktop icon: the document, a centered name under it and an optional
 * info line (Finder's "Show item info"). Hover or keyboard focus inside the
 * tile (or `selected`) shows Finder's selected state: a grey ring around the icon (no fill)
 * and a solid pill behind the name. Wrap it in a link or button yourself;
 * the selected state follows `:focus-visible` anywhere inside.
 */
export function FileTile({
  name,
  info,
  media,
  selected = false,
  className,
}: {
  /** Full name; shown middle-truncated, announced in full. */
  name: string;
  /** Second line under the name, e.g. "artist · Sep 2026". */
  info?: ReactNode;
  /** The media that fills the page (an <img>, a placeholder). */
  media?: ReactNode;
  /** Hold the selected state (e.g. the item a menu or preview belongs to). */
  selected?: boolean;
  className?: string;
}) {
  /* Spans only, so the tile is valid inside the <a> or <button> you wrap it in. */
  return (
    <span data-selected={selected || undefined} className={cn("group flex w-full flex-col items-center gap-1", className)}>
      <span
        className={cn(
          "block w-full rounded-[10px] p-1.5 ring-2 ring-transparent ring-inset transition-shadow duration-150 ease-out motion-reduce:transition-none",
          "group-hover:ring-muted-foreground/40 group-data-[selected]:ring-muted-foreground/40",
          "group-has-[:focus-visible]:ring-ring in-[:is(a,button):focus-visible]:ring-ring",
        )}
      >
        <span className="mx-auto block w-[78%]">
          <FileIcon>{media}</FileIcon>
        </span>
      </span>
      <span
        title={name}
        className={cn(
          "block max-w-full rounded-[5px] px-1.5 py-px text-center text-[12px] leading-[15px] font-medium [overflow-wrap:anywhere] text-foreground transition-colors duration-150 ease-out motion-reduce:transition-none",
          "group-hover:bg-foreground/75 group-hover:text-background group-data-[selected]:bg-foreground/75 group-data-[selected]:text-background",
          "group-has-[:focus-visible]:bg-foreground/75 group-has-[:focus-visible]:text-background in-[:is(a,button):focus-visible]:bg-foreground/75 in-[:is(a,button):focus-visible]:text-background",
        )}
      >
        <span aria-hidden>{middleTruncate(name)}</span>
        <span className="sr-only">{name}</span>
      </span>
      {info ? <span className="block max-w-full truncate text-center text-[11px] leading-[14px] text-muted-foreground">{info}</span> : null}
    </span>
  );
}