Web InnoventixFreeCode

File Manager Grid

Original · free

file manager grid view

byWeb InnoventixReact + Tailwind
filemgrgridfilemanager
Open

Copy prompt gives Claude Code, Cursor or v0 a ready-to-paste prompt that recreates this exact component.

npx shadcn@latest add https://webinnoventix.com/r/filemgr-grid.json
filemgr-grid.tsx
"use client";

import { useCallback, useMemo, useRef, useState, type KeyboardEvent } from "react";
import { motion, useReducedMotion } from "motion/react";

type FileKind = "folder" | "image" | "video" | "audio" | "pdf" | "sheet" | "code" | "archive";

type FileNode = {
  id: string;
  name: string;
  kind: FileKind;
  size: number;
  modified: string;
  owner: string;
  starred: boolean;
  items?: number;
};

type SortKey = "name" | "modified" | "size";

const FILES: FileNode[] = [
  { id: "f-brand", name: "Brand System 2026", kind: "folder", size: 0, modified: "2026-07-14T09:12:00Z", owner: "Salman R.", starred: true, items: 34 },
  { id: "f-legal", name: "Contracts & MSAs", kind: "folder", size: 0, modified: "2026-07-11T16:40:00Z", owner: "Priya N.", starred: false, items: 12 },
  { id: "f-raw", name: "Raw Footage — Q3", kind: "folder", size: 0, modified: "2026-07-09T08:05:00Z", owner: "Dev K.", starred: false, items: 208 },
  { id: "d-deck", name: "Series-A Deck v11.pdf", kind: "pdf", size: 8_912_000, modified: "2026-07-16T18:22:00Z", owner: "Salman R.", starred: true },
  { id: "d-burn", name: "Burn Model FY27.xlsx", kind: "sheet", size: 1_204_000, modified: "2026-07-16T11:03:00Z", owner: "Priya N.", starred: false },
  { id: "d-hero", name: "hero-render-4k.png", kind: "image", size: 24_600_000, modified: "2026-07-15T21:47:00Z", owner: "Dev K.", starred: false },
  { id: "d-cut", name: "launch-teaser-cut03.mp4", kind: "video", size: 486_300_000, modified: "2026-07-15T14:18:00Z", owner: "Dev K.", starred: true },
  { id: "d-vo", name: "voiceover-take7.wav", kind: "audio", size: 62_100_000, modified: "2026-07-13T10:31:00Z", owner: "Aisha M.", starred: false },
  { id: "d-api", name: "gateway.config.ts", kind: "code", size: 18_400, modified: "2026-07-12T19:55:00Z", owner: "Salman R.", starred: false },
  { id: "d-arch", name: "site-backup-0712.zip", kind: "archive", size: 1_940_000_000, modified: "2026-07-12T02:14:00Z", owner: "Automation", starred: false },
  { id: "d-icons", name: "icon-sheet-outline.svg", kind: "image", size: 341_000, modified: "2026-07-10T13:26:00Z", owner: "Aisha M.", starred: false },
  { id: "d-notes", name: "Board Notes — July.pdf", kind: "pdf", size: 620_000, modified: "2026-07-08T07:44:00Z", owner: "Priya N.", starred: true },
];

const KIND_LABEL: Record<FileKind, string> = {
  folder: "Folder",
  image: "Image",
  video: "Video",
  audio: "Audio",
  pdf: "PDF",
  sheet: "Spreadsheet",
  code: "Code",
  archive: "Archive",
};

const KIND_TONE: Record<FileKind, string> = {
  folder: "text-amber-600 dark:text-amber-400",
  image: "text-violet-600 dark:text-violet-400",
  video: "text-rose-600 dark:text-rose-400",
  audio: "text-sky-600 dark:text-sky-400",
  pdf: "text-red-600 dark:text-red-400",
  sheet: "text-emerald-600 dark:text-emerald-400",
  code: "text-indigo-600 dark:text-indigo-400",
  archive: "text-zinc-600 dark:text-zinc-400",
};

const KIND_WASH: Record<FileKind, string> = {
  folder: "bg-amber-50 dark:bg-amber-500/10",
  image: "bg-violet-50 dark:bg-violet-500/10",
  video: "bg-rose-50 dark:bg-rose-500/10",
  audio: "bg-sky-50 dark:bg-sky-500/10",
  pdf: "bg-red-50 dark:bg-red-500/10",
  sheet: "bg-emerald-50 dark:bg-emerald-500/10",
  code: "bg-indigo-50 dark:bg-indigo-500/10",
  archive: "bg-zinc-100 dark:bg-zinc-500/10",
};

function KindIcon({ kind }: { kind: FileKind }) {
  const common = { fill: "none", stroke: "currentColor", strokeWidth: 1.5, strokeLinecap: "round" as const, strokeLinejoin: "round" as const };
  switch (kind) {
    case "folder":
      return (
        <svg viewBox="0 0 24 24" aria-hidden="true" className="h-7 w-7" {...common}>
          <path d="M3 7.5A1.5 1.5 0 0 1 4.5 6h4.129a1.5 1.5 0 0 1 1.06.44l1.372 1.37a1.5 1.5 0 0 0 1.06.44H19.5A1.5 1.5 0 0 1 21 9.75v7.75A1.5 1.5 0 0 1 19.5 19h-15A1.5 1.5 0 0 1 3 17.5z" />
        </svg>
      );
    case "image":
      return (
        <svg viewBox="0 0 24 24" aria-hidden="true" className="h-7 w-7" {...common}>
          <rect x="3.5" y="4.5" width="17" height="15" rx="2" />
          <circle cx="9" cy="10" r="1.75" />
          <path d="m4.5 17 4.2-4.2a1.5 1.5 0 0 1 2.1 0l3.4 3.4m0 0 1.9-1.9a1.5 1.5 0 0 1 2.1 0l1.3 1.3" />
        </svg>
      );
    case "video":
      return (
        <svg viewBox="0 0 24 24" aria-hidden="true" className="h-7 w-7" {...common}>
          <rect x="2.5" y="5.5" width="13" height="13" rx="2" />
          <path d="m15.5 10.5 4.3-2.65a.75.75 0 0 1 1.2.6v7.1a.75.75 0 0 1-1.2.6l-4.3-2.65z" />
        </svg>
      );
    case "audio":
      return (
        <svg viewBox="0 0 24 24" aria-hidden="true" className="h-7 w-7" {...common}>
          <path d="M9.5 15.5V5.75L19 4v9.25" />
          <circle cx="7" cy="16.5" r="2.5" />
          <circle cx="16.5" cy="14.5" r="2.5" />
        </svg>
      );
    case "pdf":
      return (
        <svg viewBox="0 0 24 24" aria-hidden="true" className="h-7 w-7" {...common}>
          <path d="M6 3.5h7.5L19 9v11.5H6z" />
          <path d="M13.5 3.5V9H19" />
          <path d="M9 16.5v-4h1.4a1.3 1.3 0 0 1 0 2.6H9m5.2 1.4v-4h1.2a2 2 0 0 1 0 4z" />
        </svg>
      );
    case "sheet":
      return (
        <svg viewBox="0 0 24 24" aria-hidden="true" className="h-7 w-7" {...common}>
          <rect x="4" y="4" width="16" height="16" rx="2" />
          <path d="M4 9.5h16M4 14.5h16M10 4v16" />
        </svg>
      );
    case "code":
      return (
        <svg viewBox="0 0 24 24" aria-hidden="true" className="h-7 w-7" {...common}>
          <path d="m8.5 8.5-4 3.5 4 3.5m7-7 4 3.5-4 3.5M13.5 5.5l-3 13" />
        </svg>
      );
    case "archive":
      return (
        <svg viewBox="0 0 24 24" aria-hidden="true" className="h-7 w-7" {...common}>
          <rect x="4" y="4" width="16" height="16" rx="2" />
          <path d="M12 4v3m0 2v3m0 2v1.5" />
          <rect x="10.5" y="15.5" width="3" height="3.5" rx="1" />
        </svg>
      );
  }
}

function StarIcon({ filled }: { filled: boolean }) {
  return (
    <svg viewBox="0 0 24 24" aria-hidden="true" className="h-4 w-4" fill={filled ? "currentColor" : "none"} stroke="currentColor" strokeWidth={1.6} strokeLinejoin="round">
      <path d="m12 4.5 2.32 4.7 5.18.76-3.75 3.65.885 5.16L12 16.33l-4.635 2.44.885-5.16L4.5 9.96l5.18-.76z" />
    </svg>
  );
}

function CheckIcon() {
  return (
    <svg viewBox="0 0 24 24" aria-hidden="true" className="h-3.5 w-3.5" fill="none" stroke="currentColor" strokeWidth={3} strokeLinecap="round" strokeLinejoin="round">
      <path d="m5 12.5 4.5 4.5L19 7" />
    </svg>
  );
}

function formatSize(bytes: number): string {
  if (bytes <= 0) return "—";
  const units = ["B", "KB", "MB", "GB"];
  let value = bytes;
  let unit = 0;
  while (value >= 1000 && unit < units.length - 1) {
    value /= 1024;
    unit += 1;
  }
  return `${value >= 100 || unit === 0 ? Math.round(value) : value.toFixed(1)} ${units[unit]}`;
}

function formatDate(iso: string): string {
  return new Date(iso).toLocaleDateString("en-GB", { day: "numeric", month: "short" });
}

export default function FileManagerGrid() {
  const reduce = useReducedMotion();
  const [query, setQuery] = useState("");
  const [sort, setSort] = useState<SortKey>("modified");
  const [starred, setStarred] = useState<Set<string>>(() => new Set(FILES.filter((f) => f.starred).map((f) => f.id)));
  const [selected, setSelected] = useState<Set<string>>(() => new Set<string>());
  const [active, setActive] = useState(0);
  const [status, setStatus] = useState("12 items");
  const cellRefs = useRef<Array<HTMLButtonElement | null>>([]);

  const visible = useMemo(() => {
    const q = query.trim().toLowerCase();
    const list = FILES.filter((f) => (q === "" ? true : f.name.toLowerCase().includes(q) || f.owner.toLowerCase().includes(q)));
    const sorted = [...list].sort((a, b) => {
      if (a.kind === "folder" && b.kind !== "folder") return -1;
      if (b.kind === "folder" && a.kind !== "folder") return 1;
      if (sort === "name") return a.name.localeCompare(b.name);
      if (sort === "size") return b.size - a.size;
      return b.modified.localeCompare(a.modified);
    });
    return sorted;
  }, [query, sort]);

  const toggleSelect = useCallback((id: string, name: string) => {
    setSelected((prev) => {
      const next = new Set(prev);
      if (next.has(id)) {
        next.delete(id);
        setStatus(`${name} deselected`);
      } else {
        next.add(id);
        setStatus(`${name} selected`);
      }
      return next;
    });
  }, []);

  const toggleStar = useCallback((id: string, name: string) => {
    setStarred((prev) => {
      const next = new Set(prev);
      if (next.has(id)) {
        next.delete(id);
        setStatus(`${name} removed from starred`);
      } else {
        next.add(id);
        setStatus(`${name} added to starred`);
      }
      return next;
    });
  }, []);

  const focusCell = useCallback((index: number) => {
    const clamped = Math.max(0, Math.min(index, cellRefs.current.length - 1));
    setActive(clamped);
    cellRefs.current[clamped]?.focus();
  }, []);

  const onGridKeyDown = useCallback(
    (event: KeyboardEvent<HTMLDivElement>) => {
      const count = visible.length;
      if (count === 0) return;
      const cols = 4;
      let next: number | null = null;
      if (event.key === "ArrowRight") next = Math.min(active + 1, count - 1);
      else if (event.key === "ArrowLeft") next = Math.max(active - 1, 0);
      else if (event.key === "ArrowDown") next = Math.min(active + cols, count - 1);
      else if (event.key === "ArrowUp") next = Math.max(active - cols, 0);
      else if (event.key === "Home") next = 0;
      else if (event.key === "End") next = count - 1;
      else if (event.key === "a" && (event.metaKey || event.ctrlKey)) {
        event.preventDefault();
        setSelected(new Set(visible.map((f) => f.id)));
        setStatus(`All ${count} items selected`);
        return;
      } else if (event.key === "Escape") {
        setSelected(new Set());
        setStatus("Selection cleared");
        return;
      }
      if (next !== null) {
        event.preventDefault();
        focusCell(next);
      }
    },
    [active, focusCell, visible],
  );

  const selectionCount = selected.size;
  const selectedBytes = useMemo(() => visible.filter((f) => selected.has(f.id)).reduce((sum, f) => sum + f.size, 0), [selected, visible]);

  return (
    <section className="relative w-full bg-slate-50 px-4 py-16 text-slate-900 sm:px-6 sm:py-20 dark:bg-slate-950 dark:text-slate-100">
      <style>{`
        @keyframes fmgrid-pop-in {
          from { opacity: 0; transform: translateY(8px) scale(.985); }
          to { opacity: 1; transform: none; }
        }
        @keyframes fmgrid-bar-slide {
          from { opacity: 0; transform: translateY(10px); }
          to { opacity: 1; transform: none; }
        }
        .fmgrid-cell { animation: fmgrid-pop-in .38s cubic-bezier(.22,1,.36,1) backwards; }
        .fmgrid-bar { animation: fmgrid-bar-slide .3s cubic-bezier(.22,1,.36,1) backwards; }
        @media (prefers-reduced-motion: reduce) {
          .fmgrid-cell, .fmgrid-bar { animation: none !important; }
        }
      `}</style>

      <div className="mx-auto w-full max-w-6xl">
        <header className="mb-8 flex flex-col gap-6 border-b border-slate-200 pb-6 sm:flex-row sm:items-end sm:justify-between dark:border-slate-800">
          <div>
            <nav aria-label="Breadcrumb" className="mb-2">
              <ol className="flex items-center gap-1.5 text-xs font-medium text-slate-500 dark:text-slate-400">
                <li>Workspace</li>
                <li aria-hidden="true">/</li>
                <li>Innoventix</li>
                <li aria-hidden="true">/</li>
                <li className="text-slate-900 dark:text-slate-100" aria-current="page">
                  Shared drive
                </li>
              </ol>
            </nav>
            <h2 className="text-2xl font-semibold tracking-tight sm:text-3xl">Shared drive</h2>
            <p className="mt-1.5 text-sm text-slate-600 dark:text-slate-400">2.4 GB of 15 GB used · synced 4 minutes ago</p>
          </div>

          <div className="flex flex-wrap items-center gap-3">
            <div className="relative">
              <label htmlFor="fmgrid-search" className="sr-only">
                Search files and owners
              </label>
              <svg viewBox="0 0 24 24" aria-hidden="true" className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" fill="none" stroke="currentColor" strokeWidth={1.8} strokeLinecap="round">
                <circle cx="11" cy="11" r="6.5" />
                <path d="m16 16 4 4" />
              </svg>
              <input
                id="fmgrid-search"
                type="search"
                value={query}
                onChange={(e) => setQuery(e.target.value)}
                placeholder="Search this drive"
                className="h-10 w-full rounded-lg border border-slate-300 bg-white pl-9 pr-3 text-sm text-slate-900 placeholder:text-slate-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-50 sm:w-60 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100 dark:placeholder:text-slate-500 dark:focus-visible:ring-offset-slate-950"
              />
            </div>

            <div>
              <label htmlFor="fmgrid-sort" className="sr-only">
                Sort files by
              </label>
              <select
                id="fmgrid-sort"
                value={sort}
                onChange={(e) => {
                  const value = e.target.value as SortKey;
                  setSort(value);
                  setStatus(`Sorted by ${value === "modified" ? "last modified" : value}`);
                }}
                className="h-10 rounded-lg border border-slate-300 bg-white px-3 text-sm font-medium text-slate-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-50 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-200 dark:focus-visible:ring-offset-slate-950"
              >
                <option value="modified">Last modified</option>
                <option value="name">Name</option>
                <option value="size">Size</option>
              </select>
            </div>
          </div>
        </header>

        <p aria-live="polite" className="sr-only">
          {status}
        </p>

        {visible.length === 0 ? (
          <div className="rounded-xl border border-dashed border-slate-300 px-6 py-16 text-center dark:border-slate-700">
            <p className="text-sm font-medium text-slate-700 dark:text-slate-300">No files match &ldquo;{query}&rdquo;</p>
            <button
              type="button"
              onClick={() => setQuery("")}
              className="mt-3 rounded-lg px-3 py-1.5 text-sm font-medium text-indigo-600 hover:bg-indigo-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 dark:text-indigo-400 dark:hover:bg-indigo-500/10"
            >
              Clear search
            </button>
          </div>
        ) : (
          <div
            role="grid"
            aria-label="Files in shared drive"
            aria-multiselectable="true"
            onKeyDown={onGridKeyDown}
            className="grid grid-cols-2 gap-3 sm:grid-cols-3 sm:gap-4 lg:grid-cols-4"
          >
            {visible.map((file, index) => {
              const isSelected = selected.has(file.id);
              const isStarred = starred.has(file.id);
              return (
                <div role="row" key={file.id} className="contents">
                  <div role="gridcell" className="relative">
                    <motion.div
                      whileHover={reduce ? undefined : { y: -3 }}
                      transition={{ type: "spring", stiffness: 380, damping: 28 }}
                      className="fmgrid-cell h-full"
                      style={{ animationDelay: `${Math.min(index * 35, 350)}ms` }}
                    >
                      <button
                        type="button"
                        ref={(node) => {
                          cellRefs.current[index] = node;
                        }}
                        tabIndex={index === active ? 0 : -1}
                        aria-selected={isSelected}
                        onFocus={() => setActive(index)}
                        onClick={() => toggleSelect(file.id, file.name)}
                        className={`group flex h-full w-full flex-col items-start rounded-xl border p-4 text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-50 dark:focus-visible:ring-offset-slate-950 ${
                          isSelected
                            ? "border-indigo-500 bg-indigo-50/70 dark:border-indigo-400 dark:bg-indigo-500/10"
                            : "border-slate-200 bg-white hover:border-slate-300 hover:bg-slate-50 dark:border-slate-800 dark:bg-slate-900 dark:hover:border-slate-700 dark:hover:bg-slate-800/60"
                        }`}
                      >
                        <div className="flex w-full items-start justify-between">
                          <span className={`inline-flex h-12 w-12 items-center justify-center rounded-lg ${KIND_WASH[file.kind]} ${KIND_TONE[file.kind]}`}>
                            <KindIcon kind={file.kind} />
                          </span>
                          <span
                            aria-hidden="true"
                            className={`mt-0.5 inline-flex h-5 w-5 items-center justify-center rounded-md border transition-colors ${
                              isSelected
                                ? "border-indigo-600 bg-indigo-600 text-white dark:border-indigo-400 dark:bg-indigo-500"
                                : "border-slate-300 bg-white text-transparent dark:border-slate-600 dark:bg-slate-800"
                            }`}
                          >
                            {isSelected ? <CheckIcon /> : null}
                          </span>
                        </div>

                        <span className="mt-4 line-clamp-2 w-full break-words text-sm font-medium leading-snug text-slate-900 dark:text-slate-100">{file.name}</span>

                        <span className="mt-1 text-xs text-slate-500 dark:text-slate-400">
                          {file.kind === "folder" ? `${file.items ?? 0} items` : formatSize(file.size)} · {formatDate(file.modified)}
                        </span>
                        <span className="mt-3 truncate pr-9 text-[11px] font-medium uppercase tracking-wide text-slate-400 dark:text-slate-500">
                          {KIND_LABEL[file.kind]} · {file.owner}
                        </span>
                      </button>
                    </motion.div>

                    <button
                      type="button"
                      onClick={() => toggleStar(file.id, file.name)}
                      aria-pressed={isStarred}
                      className={`absolute bottom-3 right-2.5 rounded-md p-1.5 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 ${
                        isStarred
                          ? "text-amber-500 dark:text-amber-400"
                          : "text-slate-300 hover:text-slate-500 dark:text-slate-600 dark:hover:text-slate-400"
                      }`}
                    >
                      <StarIcon filled={isStarred} />
                      <span className="sr-only">{isStarred ? `Unstar ${file.name}` : `Star ${file.name}`}</span>
                    </button>
                  </div>
                </div>
              );
            })}
          </div>
        )}

        {selectionCount > 0 ? (
          <div className="fmgrid-bar sticky bottom-4 z-10 mx-auto mt-6 flex w-full max-w-xl flex-wrap items-center justify-between gap-3 rounded-xl border border-slate-800 bg-slate-900 px-4 py-3 text-slate-100 shadow-lg dark:border-slate-700 dark:bg-slate-800">
            <p className="text-sm font-medium">
              {selectionCount} selected
              {selectedBytes > 0 ? <span className="ml-2 text-slate-400">{formatSize(selectedBytes)}</span> : null}
            </p>
            <div className="flex items-center gap-2">
              <button
                type="button"
                onClick={() => {
                  setStarred((prev) => {
                    const next = new Set(prev);
                    selected.forEach((id) => next.add(id));
                    return next;
                  });
                  setStatus(`${selectionCount} items starred`);
                }}
                className="rounded-lg px-3 py-1.5 text-sm font-medium text-slate-200 hover:bg-slate-800 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-400 dark:hover:bg-slate-700"
              >
                Star
              </button>
              <button
                type="button"
                onClick={() => {
                  setSelected(new Set());
                  setStatus("Selection cleared");
                }}
                className="rounded-lg bg-indigo-500 px-3 py-1.5 text-sm font-medium text-white hover:bg-indigo-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-300"
              >
                Clear
              </button>
            </div>
          </div>
        ) : null}

        <p className="mt-6 text-xs text-slate-500 dark:text-slate-400">
          Arrow keys move between files · Enter or Space toggles selection · Ctrl/Cmd + A selects all · Esc clears
        </p>
      </div>
    </section>
  );
}

Dependencies

motion

Licence

Built by Web Innoventix. Free for personal and commercial use, no attribution required.

Built by Web Innoventix

Need a custom interface, a full website, or SEO that gets you cited by AI? We design, build and rank it end to end.

Get a free quote

Similar components

Browse all →