Cards

File Card

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.

Install

npx shadcn@latest add @paragon/file-card

file-card.tsx

"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>
  );
}