# File Preview Grid — React File Component

> A grid of file tiles colour-coded by type, each with a drawn preview; tiles lift on hover to reveal actions and a selection count rolls in the header.

- Category: [Files & Uploads](https://pixelpapa.com/components/files.md)
- Tags: files, grid, preview, selection, hover
- Dependencies: lucide-react, motion (plus React and Tailwind CSS v4)
- Last updated: 2026-11-13
- Live preview: https://pixelpapa.com/components/files/file-preview-grid

## Install

```bash
npx shadcn@latest add "https://pixelpapa.com/r/file-preview-grid.json"
```

The file is written to `components/pixelpapa/file-preview-grid.tsx`. To install manually, run `npm install lucide-react motion` and copy the source below into your project. It imports `cn` from `@/lib/utils`, which every shadcn project has.

## Source

```tsx
"use client";

import { Check, Download, Ellipsis, FileCode, FileSpreadsheet, FileText, Film, Image as ImageIcon, Music, type LucideIcon } from "lucide-react";
import { AnimatePresence, motion } from "motion/react";
import { useState } from "react";
import { cn } from "@/lib/utils";

type Kind = "image" | "pdf" | "sheet" | "video" | "audio" | "code";
export type GridFile = { id: string; name: string; kind: Kind; size: string };

const kinds: Record<Kind, { icon: LucideIcon; tint: string; surface: string }> = {
  image: { icon: ImageIcon, tint: "text-violet-500", surface: "bg-violet-500/10" },
  pdf: { icon: FileText, tint: "text-rose-500", surface: "bg-rose-500/10" },
  sheet: { icon: FileSpreadsheet, tint: "text-emerald-500", surface: "bg-emerald-500/10" },
  video: { icon: Film, tint: "text-sky-500", surface: "bg-sky-500/10" },
  audio: { icon: Music, tint: "text-amber-500", surface: "bg-amber-500/10" },
  code: { icon: FileCode, tint: "text-indigo-500", surface: "bg-indigo-500/10" },
};

const bars = [5, 9, 14, 8, 16, 11, 6, 13, 17, 9, 12, 5, 10, 15, 7];

/** A tiny abstract picture of what is inside, drawn with CSS only. */
function Art({ kind }: { kind: Kind }) {
  if (kind === "image")
    return (
      <div className="absolute inset-0 bg-linear-to-br from-violet-400/50 via-fuchsia-300/40 to-amber-200/50">
        <span className="absolute top-3 right-4 size-4 rounded-full bg-amber-200" />
        <span className="absolute -bottom-6 -left-2 size-20 rotate-45 rounded-lg bg-violet-500/50" />
        <span className="absolute right-2 -bottom-8 size-16 rotate-45 rounded-lg bg-fuchsia-500/40" />
      </div>
    );
  if (kind === "video")
    return (
      <div className="absolute inset-0 flex items-center justify-center bg-linear-to-br from-sky-500/40 to-indigo-500/40">
        <span className="flex size-8 items-center justify-center rounded-full bg-white/90 shadow">
          <span className="ml-0.5 border-y-[5px] border-l-[8px] border-y-transparent border-l-sky-600" />
        </span>
      </div>
    );
  if (kind === "audio")
    return (
      <div className="absolute inset-0 flex items-center justify-center gap-[3px]">
        {bars.map((h, i) => (
          <span key={i} className="w-[3px] rounded-full bg-amber-500/70" style={{ height: h * 2 }} />
        ))}
      </div>
    );
  if (kind === "sheet")
    return (
      <div className="absolute inset-x-4 top-3 -bottom-2 grid grid-cols-3 gap-px overflow-hidden rounded-md bg-emerald-500/25 shadow-sm">
        {Array.from({ length: 12 }, (_, i) => (
          <span key={i} className={cn("bg-card", i < 3 && "bg-emerald-500/20")} />
        ))}
      </div>
    );
  return (
    <div className="absolute inset-x-5 top-3 -bottom-2 space-y-1.5 rounded-md bg-card p-2.5 shadow-sm">
      {[70, 100, 85, 40, 92].map((w, i) => (
        <span key={i} className={cn("block h-1 rounded-full", kind === "pdf" ? (i === 0 ? "bg-rose-500/60" : "bg-muted-foreground/20") : i % 2 ? "bg-indigo-500/50" : "bg-muted-foreground/20")} style={{ width: `${w}%`, marginLeft: kind === "code" && i % 3 === 1 ? 8 : 0 }} />
      ))}
    </div>
  );
}

type FileGridProps = {
  files: GridFile[];
  defaultSelected?: string[];
  onSelectionChange?: (ids: string[]) => void;
  title?: string;
  className?: string;
};

export function FileGrid({ files, defaultSelected = [], onSelectionChange, title = "Files", className }: FileGridProps) {
  const [selected, setSelected] = useState(defaultSelected);
  const update = (ids: string[]) => (setSelected(ids), onSelectionChange?.(ids));
  const action = "flex size-7 items-center justify-center rounded-md bg-background/90 text-foreground shadow-sm outline-none backdrop-blur hover:bg-background focus-visible:ring-2 focus-visible:ring-ring/60";

  return (
    <div className={cn("w-full", className)}>
      <div className="mb-3 flex h-7 items-center justify-between px-0.5">
        <p aria-live="polite" className="flex items-center gap-1.5 text-sm font-semibold text-foreground">
          {selected.length === 0 ? (
            title
          ) : (
            <>
              <span className="relative inline-flex h-5 overflow-hidden tabular-nums">
                <AnimatePresence mode="popLayout" initial={false}>
                  <motion.span key={selected.length} initial={{ y: "100%" }} animate={{ y: 0 }} exit={{ y: "-100%" }} transition={{ type: "spring", stiffness: 500, damping: 34 }}>
                    {selected.length}
                  </motion.span>
                </AnimatePresence>
              </span>
              selected
            </>
          )}
        </p>
        <button
          type="button"
          onClick={() => update(selected.length ? [] : files.map((f) => f.id))}
          className="rounded-md px-2 py-1 text-xs font-medium text-muted-foreground outline-none hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/60"
        >
          {selected.length ? "Clear" : "Select all"}
        </button>
      </div>

      <ul className="grid grid-cols-2 gap-3 sm:grid-cols-4">
        {files.map((f, i) => {
          const on = selected.includes(f.id);
          const { icon: Icon, tint, surface } = kinds[f.kind];
          return (
            <motion.li
              key={f.id}
              initial={{ opacity: 0, y: 12 }}
              animate={{ opacity: 1, y: 0 }}
              transition={{ duration: 0.4, delay: i * 0.04, ease: [0.22, 1, 0.36, 1] }}
              whileHover={{ y: -3 }}
              className={cn(
                "group relative overflow-hidden rounded-xl border bg-card transition-[box-shadow,border-color] duration-200 hover:shadow-lg hover:shadow-black/5",
                on ? "border-primary ring-2 ring-primary/25" : "border-border",
              )}
            >
              <div className={cn("relative h-[88px] overflow-hidden", surface)}>
                <Art kind={f.kind} />
              </div>
              <div className="flex items-center gap-2 border-t border-border px-2.5 py-2">
                <Icon className={cn("size-4 shrink-0", tint)} />
                <div className="min-w-0">
                  <p className="truncate text-xs font-medium text-card-foreground">{f.name}</p>
                  <p className="text-[11px] text-muted-foreground">{f.size}</p>
                </div>
              </div>
              {/* Whole-tile toggle sits under the action buttons. */}
              <button type="button" aria-pressed={on} aria-label={`Select ${f.name}`} onClick={() => update(on ? selected.filter((id) => id !== f.id) : [...selected, f.id])} className="absolute inset-0 rounded-xl outline-none focus-visible:ring-2 focus-visible:ring-ring/70 focus-visible:ring-inset" />
              <span
                aria-hidden
                className={cn(
                  "pointer-events-none absolute top-2 left-2 flex size-5 items-center justify-center rounded-full border transition-all duration-200",
                  on ? "scale-100 border-primary bg-primary text-primary-foreground" : "scale-75 border-foreground/30 bg-background/80 opacity-0 group-hover:scale-100 group-hover:opacity-100",
                )}
              >
                {on && <Check className="size-3" strokeWidth={3.5} />}
              </span>
              <div className="absolute top-2 right-2 flex translate-y-1 gap-1 opacity-0 transition-all duration-200 group-focus-within:translate-y-0 group-focus-within:opacity-100 group-hover:translate-y-0 group-hover:opacity-100">
                <button type="button" aria-label={`Download ${f.name}`} className={action}>
                  <Download className="size-3.5" />
                </button>
                <button type="button" aria-label={`More actions for ${f.name}`} className={action}>
                  <Ellipsis className="size-3.5" />
                </button>
              </div>
            </motion.li>
          );
        })}
      </ul>
    </div>
  );
}

const files: GridFile[] = [
  { id: "g1", name: "hero-sunrise.png", kind: "image", size: "2.4 MB" },
  { id: "g2", name: "halden-msa.pdf", kind: "pdf", size: "412 KB" },
  { id: "g3", name: "forecast-q1.xlsx", kind: "sheet", size: "204 KB" },
  { id: "g4", name: "launch-teaser.mp4", kind: "video", size: "148 MB" },
  { id: "g5", name: "standup-nov-12.m4a", kind: "audio", size: "18 MB" },
  { id: "g6", name: "usage-chart.tsx", kind: "code", size: "6 KB" },
  { id: "g7", name: "team-offsite.png", kind: "image", size: "3.1 MB" },
  { id: "g8", name: "onboarding-guide.pdf", kind: "pdf", size: "1.2 MB" },
];

export default function FilePreviewGridDemo() {
  return <FileGrid files={files} defaultSelected={["g2", "g3"]} title="Shared with you" className="max-w-2xl" />;
}
```

## AI prompt

```text
Add the "File Preview Grid" component from PixelPapa UI to this project.

What it is: A grid of file tiles colour-coded by type, each with a drawn preview; tiles lift on hover to reveal actions and a selection count rolls in the header.
Category: Files & Uploads. Depends on: lucide-react, motion.

1. Install it with the shadcn CLI:
   npx shadcn@latest add "https://pixelpapa.com/r/file-preview-grid.json"
   (If the CLI is unavailable, fetch that URL, write files[0].content to components/pixelpapa/file-preview-grid.tsx and install the listed dependencies.)
2. Import it from "@/components/pixelpapa/file-preview-grid" and place it where I describe below.
3. Keep its animation timings and accessibility attributes. Replace the demo copy and data with content that fits my product, and use my existing theme tokens (bg-background, text-foreground, bg-primary, border-border) rather than hard-coded colours.

Where to use it: <describe the page and position here>
```

## More files & uploads

- [File Attachment Chips](https://pixelpapa.com/components/files/file-attachment-chips.md): Attachment chips with a type-coloured icon and size; new chips pop in, removed ones collapse, and the rest reflow with spring layout animation.
- [File List Progress](https://pixelpapa.com/components/files/file-list-progress.md) (Pro): An upload queue with pause, resume, cancel and retry: two files transfer at a time, queued ones start on their own, and finished rows settle with a drawn check.
- [File Tree Explorer](https://pixelpapa.com/components/files/file-tree-explorer.md) (Pro): A file tree with spring-animated folders, indent guides and full arrow-key navigation; the selection highlight slides between rows.
- [File Upload Dropzone](https://pixelpapa.com/components/files/file-upload-dropzone.md): A dropzone that lifts and glows while files hover over it, then lists each file with its own simulated progress bar, a drawn check on completion and a remove button.
