A grid-ready file tile with an extension monogram, CSS-only middle-ellipsis name truncation, selection ring with check blur-swap, and a hover context-menu affordance.
npx shadcn@latest add @paragon/file-card"use client";
import * as React from "react";
import { AnimatePresence, motion } from "motion/react";
import { Check, MoreHorizontal } from "lucide-react";
import { cn } from "@/lib/utils";
const monogramTints: Record<string, string> = {
pdf: "bg-red-500/10 text-red-600 dark:text-red-400",
doc: "bg-blue-500/10 text-blue-600 dark:text-blue-400",
docx: "bg-blue-500/10 text-blue-600 dark:text-blue-400",
xls: "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400",
xlsx: "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400",
csv: "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400",
ppt: "bg-orange-500/10 text-orange-600 dark:text-orange-400",
pptx: "bg-orange-500/10 text-orange-600 dark:text-orange-400",
fig: "bg-violet-500/10 text-violet-600 dark:text-violet-400",
png: "bg-sky-500/10 text-sky-600 dark:text-sky-400",
jpg: "bg-sky-500/10 text-sky-600 dark:text-sky-400",
svg: "bg-sky-500/10 text-sky-600 dark:text-sky-400",
zip: "bg-amber-500/10 text-amber-600 dark:text-amber-400",
};
function splitName(name: string) {
const dot = name.lastIndexOf(".");
const stem = dot > 0 ? name.slice(0, dot) : name;
const extension = dot > 0 ? name.slice(dot + 1) : "";
return { stem, extension };
}
/**
* CSS-only middle ellipsis: the head truncates while the tail (last few
* characters + extension) never shrinks, so the discriminating end of long
* names — "…-final-v3.pdf" — stays visible at any width.
*/
function MiddleEllipsisName({ name }: { name: string }) {
const { stem, extension } = splitName(name);
const keep = 7;
const shouldSplit = stem.length > keep + 4;
const head = shouldSplit ? stem.slice(0, stem.length - keep) : stem;
const tail = shouldSplit ? stem.slice(stem.length - keep) : "";
const suffix = extension ? `.${extension}` : "";
return (
<span className="flex min-w-0 max-w-full" title={name} aria-label={name}>
<span aria-hidden className="min-w-0 truncate">
{head}
</span>
<span aria-hidden className="shrink-0 whitespace-pre">
{tail}
{suffix}
</span>
</span>
);
}
export interface FileCardProps
extends Omit<React.ComponentProps<"button">, "name"> {
/** Full filename including extension. */
name: string;
/** Meta row, e.g. "2.4 MB" — joined with a middle dot. */
size?: string;
/** Meta row, e.g. "Edited 2d ago". */
modified?: string;
/** Selected state: ring + check badge. */
selected?: boolean;
/** Fires with the next state when the tile is clicked. */
onSelectedChange?: (selected: boolean) => void;
/** Renders the "more" affordance and receives its clicks. */
onMenu?: (event: React.MouseEvent<HTMLButtonElement>) => void;
/** Disables press motion. */
static?: boolean;
}
/**
* A file tile for document grids. The extension monogram carries the file
* identity; selection swaps in a check via the house icon-swap (blur +
* scale, popLayout); the context-menu affordance surfaces on hover on fine
* pointers and stays reachable via keyboard focus everywhere.
*/
export function FileCard({
name,
size,
modified,
selected = false,
onSelectedChange,
onMenu,
static: isStatic = false,
className,
onClick,
...props
}: FileCardProps) {
const { extension } = splitName(name);
const monogram = (extension || "file").slice(0, 4).toUpperCase();
const tint =
monogramTints[extension.toLowerCase()] ??
"bg-muted text-muted-foreground";
const meta = [size, modified].filter(Boolean).join(" · ");
return (
<div className={cn("group/file relative", className)}>
<button
type="button"
data-slot="file-card"
aria-pressed={selected}
onClick={(event) => {
onClick?.(event);
if (!event.defaultPrevented) onSelectedChange?.(!selected);
}}
className={cn(
"flex w-full flex-col items-start gap-3 rounded-xl bg-card p-4 text-left shadow-border transition-[box-shadow,scale] duration-150 ease-out hover:shadow-border-hover",
!isStatic && "active:not-disabled:scale-[0.98]",
selected &&
"shadow-none ring-2 ring-ring ring-offset-2 ring-offset-background",
)}
{...props}
>
<span
aria-hidden
className={cn(
"flex h-10 w-12 items-center justify-center rounded-md text-[11px] font-semibold tracking-wide",
tint,
)}
>
{monogram}
</span>
<span className="flex w-full min-w-0 flex-col gap-0.5">
<span className="w-full min-w-0 pr-5 text-sm font-medium">
<MiddleEllipsisName name={name} />
</span>
{meta ? (
<span className="text-xs text-muted-foreground tabular-nums">
{meta}
</span>
) : null}
</span>
<span
aria-hidden
className={cn(
"absolute top-3 right-3 flex size-5 items-center justify-center rounded-full bg-primary text-primary-foreground transition-opacity duration-(--duration-quick) ease-out",
selected ? "opacity-100" : "pointer-events-none opacity-0",
)}
>
<AnimatePresence mode="popLayout" initial={false}>
{selected ? (
<motion.span
key="check"
initial={{ opacity: 0, scale: 0.25, filter: "blur(4px)" }}
animate={{ opacity: 1, scale: 1, filter: "blur(0px)" }}
exit={{ opacity: 0, scale: 0.25, filter: "blur(4px)" }}
transition={{ type: "spring", duration: 0.3, bounce: 0 }}
className="flex"
>
<Check className="size-3" strokeWidth={3} />
</motion.span>
) : null}
</AnimatePresence>
</span>
</button>
{onMenu ? (
<button
type="button"
aria-label={`Actions for ${name}`}
onClick={onMenu}
className={cn(
"pressable absolute right-2.5 bottom-2.5 flex size-7 items-center justify-center rounded-md text-muted-foreground transition-[opacity,background-color,color] duration-150 ease-out hover:bg-accent hover:text-accent-foreground focus-visible:opacity-100 after:absolute after:top-1/2 after:left-1/2 after:size-10 after:-translate-1/2",
"[@media(hover:hover)_and_(pointer:fine)]:opacity-0 [@media(hover:hover)_and_(pointer:fine)]:group-hover/file:opacity-100 [@media(hover:hover)_and_(pointer:fine)]:group-focus-within/file:opacity-100",
)}
>
<MoreHorizontal className="size-4" />
</button>
) : null}
</div>
);
}