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.
Loading preview…
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>
);
}