Web InnoventixFreeCode

File Manager List

Original · free

file manager list view

byWeb InnoventixReact + Tailwind
filemgrlistfilemanager
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-list.json
filemgr-list.tsx
"use client";

import {
  useEffect,
  useId,
  useRef,
  useState,
  type KeyboardEvent,
  type ReactNode,
} from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";

type NodeKind =
  | "folder"
  | "doc"
  | "pdf"
  | "image"
  | "sheet"
  | "video"
  | "code"
  | "zip"
  | "audio"
  | "design";

type FileNode = {
  id: string;
  name: string;
  kind: NodeKind;
  size: number;
  modified: number;
  modifiedLabel: string;
  owner: string;
  children?: string[];
};

type SortKey = "name" | "owner" | "size" | "modified";
type SortDir = "asc" | "desc";

const mb = (n: number): number => Math.round(n * 1024 * 1024);
const gb = (n: number): number => Math.round(n * 1024 * 1024 * 1024);
const at = (y: number, m: number, d: number, h: number, min: number): number =>
  Date.UTC(y, m - 1, d, h, min);

const CAPACITY = gb(12);

const TREE: Record<string, FileNode> = {
  root: {
    id: "root",
    name: "Innoventix Drive",
    kind: "folder",
    size: 0,
    modified: at(2026, 7, 16, 9, 5),
    modifiedLabel: "Jul 16, 2026 · 9:05 AM",
    owner: "Salman Ali",
    children: ["clients", "brand", "eng", "fin", "proposal", "roadmap", "launch"],
  },

  clients: {
    id: "clients",
    name: "Client Deliverables",
    kind: "folder",
    size: 0,
    modified: at(2026, 7, 15, 18, 40),
    modifiedLabel: "Jul 15, 2026 · 6:40 PM",
    owner: "Priya Raman",
    children: ["c-northwind", "c-halcyon", "handover", "sow"],
  },
  brand: {
    id: "brand",
    name: "Brand Kit",
    kind: "folder",
    size: 0,
    modified: at(2026, 6, 28, 11, 12),
    modifiedLabel: "Jun 28, 2026 · 11:12 AM",
    owner: "Amara Osei",
    children: ["logo-pack", "guide", "palette", "jingle"],
  },
  eng: {
    id: "eng",
    name: "Engineering",
    kind: "folder",
    size: 0,
    modified: at(2026, 7, 14, 16, 12),
    modifiedLabel: "Jul 14, 2026 · 4:12 PM",
    owner: "Dev Kapoor",
    children: ["repo", "deploy", "schema", "standup"],
  },
  fin: {
    id: "fin",
    name: "Finance 2026",
    kind: "folder",
    size: 0,
    modified: at(2026, 7, 2, 8, 30),
    modifiedLabel: "Jul 02, 2026 · 8:30 AM",
    owner: "Tomas Rehak",
    children: ["inv-041", "inv-042", "ledger"],
  },
  proposal: {
    id: "proposal",
    name: "q3-growth-proposal.pdf",
    kind: "pdf",
    size: mb(4.2),
    modified: at(2026, 7, 16, 9, 5),
    modifiedLabel: "Jul 16, 2026 · 9:05 AM",
    owner: "Salman Ali",
  },
  roadmap: {
    id: "roadmap",
    name: "roadmap-q3-q4.xlsx",
    kind: "sheet",
    size: mb(1.1),
    modified: at(2026, 7, 13, 14, 48),
    modifiedLabel: "Jul 13, 2026 · 2:48 PM",
    owner: "Priya Raman",
  },
  launch: {
    id: "launch",
    name: "launch-film-master.mov",
    kind: "video",
    size: gb(2.4),
    modified: at(2026, 6, 21, 22, 3),
    modifiedLabel: "Jun 21, 2026 · 10:03 PM",
    owner: "Amara Osei",
  },

  "c-northwind": {
    id: "c-northwind",
    name: "Northwind Retail",
    kind: "folder",
    size: 0,
    modified: at(2026, 7, 15, 18, 40),
    modifiedLabel: "Jul 15, 2026 · 6:40 PM",
    owner: "Priya Raman",
    children: ["nw-audit", "nw-shot", "nw-notes"],
  },
  "c-halcyon": {
    id: "c-halcyon",
    name: "Halcyon Labs",
    kind: "folder",
    size: 0,
    modified: at(2026, 7, 9, 10, 19),
    modifiedLabel: "Jul 09, 2026 · 10:19 AM",
    owner: "Dev Kapoor",
    children: ["hl-brief", "hl-wire"],
  },
  handover: {
    id: "handover",
    name: "handover-checklist.docx",
    kind: "doc",
    size: mb(0.6),
    modified: at(2026, 7, 11, 17, 2),
    modifiedLabel: "Jul 11, 2026 · 5:02 PM",
    owner: "Tomas Rehak",
  },
  sow: {
    id: "sow",
    name: "statement-of-work-v4.pdf",
    kind: "pdf",
    size: mb(2.8),
    modified: at(2026, 7, 6, 12, 55),
    modifiedLabel: "Jul 06, 2026 · 12:55 PM",
    owner: "Salman Ali",
  },

  "nw-audit": {
    id: "nw-audit",
    name: "northwind-seo-audit.pdf",
    kind: "pdf",
    size: mb(9.4),
    modified: at(2026, 7, 15, 18, 40),
    modifiedLabel: "Jul 15, 2026 · 6:40 PM",
    owner: "Priya Raman",
  },
  "nw-shot": {
    id: "nw-shot",
    name: "storefront-hero-01.png",
    kind: "image",
    size: mb(12.7),
    modified: at(2026, 7, 12, 15, 26),
    modifiedLabel: "Jul 12, 2026 · 3:26 PM",
    owner: "Amara Osei",
  },
  "nw-notes": {
    id: "nw-notes",
    name: "kickoff-notes.docx",
    kind: "doc",
    size: mb(0.3),
    modified: at(2026, 6, 30, 9, 41),
    modifiedLabel: "Jun 30, 2026 · 9:41 AM",
    owner: "Tomas Rehak",
  },
  "hl-brief": {
    id: "hl-brief",
    name: "halcyon-brief.docx",
    kind: "doc",
    size: mb(0.9),
    modified: at(2026, 7, 9, 10, 19),
    modifiedLabel: "Jul 09, 2026 · 10:19 AM",
    owner: "Dev Kapoor",
  },
  "hl-wire": {
    id: "hl-wire",
    name: "halcyon-wireframes.fig",
    kind: "design",
    size: mb(31.5),
    modified: at(2026, 7, 8, 20, 14),
    modifiedLabel: "Jul 08, 2026 · 8:14 PM",
    owner: "Amara Osei",
  },

  "logo-pack": {
    id: "logo-pack",
    name: "logo-pack-2026.zip",
    kind: "zip",
    size: mb(184),
    modified: at(2026, 6, 28, 11, 12),
    modifiedLabel: "Jun 28, 2026 · 11:12 AM",
    owner: "Amara Osei",
  },
  guide: {
    id: "guide",
    name: "brand-guidelines-v3.pdf",
    kind: "pdf",
    size: mb(18.4),
    modified: at(2026, 6, 24, 13, 37),
    modifiedLabel: "Jun 24, 2026 · 1:37 PM",
    owner: "Salman Ali",
  },
  palette: {
    id: "palette",
    name: "colour-system.fig",
    kind: "design",
    size: mb(22.1),
    modified: at(2026, 6, 19, 16, 8),
    modifiedLabel: "Jun 19, 2026 · 4:08 PM",
    owner: "Amara Osei",
  },
  jingle: {
    id: "jingle",
    name: "sonic-logo-master.wav",
    kind: "audio",
    size: mb(46.3),
    modified: at(2026, 5, 30, 19, 22),
    modifiedLabel: "May 30, 2026 · 7:22 PM",
    owner: "Tomas Rehak",
  },

  repo: {
    id: "repo",
    name: "site-archive-jul-2026.zip",
    kind: "zip",
    size: mb(902),
    modified: at(2026, 7, 14, 16, 12),
    modifiedLabel: "Jul 14, 2026 · 4:12 PM",
    owner: "Dev Kapoor",
  },
  deploy: {
    id: "deploy",
    name: "deploy-pipeline.yml",
    kind: "code",
    size: mb(0.02),
    modified: at(2026, 7, 14, 9, 3),
    modifiedLabel: "Jul 14, 2026 · 9:03 AM",
    owner: "Dev Kapoor",
  },
  schema: {
    id: "schema",
    name: "db-schema.sql",
    kind: "code",
    size: mb(0.14),
    modified: at(2026, 7, 3, 11, 49),
    modifiedLabel: "Jul 03, 2026 · 11:49 AM",
    owner: "Dev Kapoor",
  },
  standup: {
    id: "standup",
    name: "standup-recording-jul-14.mp4",
    kind: "video",
    size: mb(487),
    modified: at(2026, 7, 14, 10, 32),
    modifiedLabel: "Jul 14, 2026 · 10:32 AM",
    owner: "Priya Raman",
  },

  "inv-041": {
    id: "inv-041",
    name: "invoice-2026-041.pdf",
    kind: "pdf",
    size: mb(0.4),
    modified: at(2026, 7, 2, 8, 30),
    modifiedLabel: "Jul 02, 2026 · 8:30 AM",
    owner: "Tomas Rehak",
  },
  "inv-042": {
    id: "inv-042",
    name: "invoice-2026-042.pdf",
    kind: "pdf",
    size: mb(0.4),
    modified: at(2026, 7, 2, 8, 31),
    modifiedLabel: "Jul 02, 2026 · 8:31 AM",
    owner: "Tomas Rehak",
  },
  ledger: {
    id: "ledger",
    name: "ledger-h1-2026.xlsx",
    kind: "sheet",
    size: mb(3.6),
    modified: at(2026, 6, 30, 17, 55),
    modifiedLabel: "Jun 30, 2026 · 5:55 PM",
    owner: "Tomas Rehak",
  },
};

const glyph = (children: ReactNode): ReactNode => (
  <svg
    viewBox="0 0 24 24"
    fill="none"
    stroke="currentColor"
    strokeWidth={1.6}
    strokeLinecap="round"
    strokeLinejoin="round"
    className="h-[18px] w-[18px]"
    aria-hidden="true"
  >
    {children}
  </svg>
);

const KIND_META: Record<NodeKind, { label: string; tone: string; icon: ReactNode }> = {
  folder: {
    label: "Folder",
    tone: "bg-amber-100 text-amber-700 dark:bg-amber-400/15 dark:text-amber-300",
    icon: glyph(
      <path d="M3 7.5A1.5 1.5 0 0 1 4.5 6h4.13a1.5 1.5 0 0 1 1.06.44l1.12 1.12a1.5 1.5 0 0 0 1.06.44H19.5A1.5 1.5 0 0 1 21 9.5v8A1.5 1.5 0 0 1 19.5 19h-15A1.5 1.5 0 0 1 3 17.5v-10Z" />,
    ),
  },
  doc: {
    label: "Document",
    tone: "bg-sky-100 text-sky-700 dark:bg-sky-400/15 dark:text-sky-300",
    icon: glyph(
      <>
        <path d="M14 3v4a1 1 0 0 0 1 1h4" />
        <path d="M19 8v11a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h7l5 5Z" />
        <path d="M9 13h6M9 17h4" />
      </>,
    ),
  },
  pdf: {
    label: "PDF",
    tone: "bg-rose-100 text-rose-700 dark:bg-rose-400/15 dark:text-rose-300",
    icon: glyph(
      <>
        <path d="M14 3v4a1 1 0 0 0 1 1h4" />
        <path d="M19 8v11a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h7l5 5Z" />
        <rect x="7.5" y="13" width="9" height="5" rx="1.2" />
      </>,
    ),
  },
  image: {
    label: "Image",
    tone: "bg-violet-100 text-violet-700 dark:bg-violet-400/15 dark:text-violet-300",
    icon: glyph(
      <>
        <rect x="3" y="5" width="18" height="14" rx="2" />
        <circle cx="15.5" cy="9.5" r="1.4" />
        <path d="M3.5 16.5 8 12a1.6 1.6 0 0 1 2.2 0l4.3 4.3" />
        <path d="M14 15l1.9-1.9a1.6 1.6 0 0 1 2.2 0L20.5 15.5" />
      </>,
    ),
  },
  sheet: {
    label: "Spreadsheet",
    tone: "bg-emerald-100 text-emerald-700 dark:bg-emerald-400/15 dark:text-emerald-300",
    icon: glyph(
      <>
        <rect x="4" y="4" width="16" height="16" rx="2" />
        <path d="M4 9.5h16M9.5 9.5V20M15 9.5V20" />
      </>,
    ),
  },
  video: {
    label: "Video",
    tone: "bg-indigo-100 text-indigo-700 dark:bg-indigo-400/15 dark:text-indigo-300",
    icon: glyph(
      <>
        <rect x="3" y="6" width="13" height="12" rx="2" />
        <path d="M16 10.5 21 7.5v9l-5-3z" />
      </>,
    ),
  },
  code: {
    label: "Code",
    tone: "bg-zinc-200 text-zinc-700 dark:bg-zinc-400/15 dark:text-zinc-300",
    icon: glyph(
      <>
        <path d="M13.5 4 10.5 20" />
        <path d="m8 8-4 4 4 4" />
        <path d="m16 8 4 4-4 4" />
      </>,
    ),
  },
  zip: {
    label: "Archive",
    tone: "bg-amber-100 text-amber-700 dark:bg-amber-400/15 dark:text-amber-300",
    icon: glyph(
      <>
        <rect x="3" y="6" width="18" height="4" rx="1" />
        <path d="M5 10v8a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2v-8" />
        <path d="M10 14h4" />
      </>,
    ),
  },
  audio: {
    label: "Audio",
    tone: "bg-sky-100 text-sky-700 dark:bg-sky-400/15 dark:text-sky-300",
    icon: glyph(
      <>
        <path d="M9 17.5V5.8l10-2v11.7" />
        <circle cx="6.6" cy="17.6" r="2.4" />
        <circle cx="16.6" cy="15.6" r="2.4" />
      </>,
    ),
  },
  design: {
    label: "Design file",
    tone: "bg-violet-100 text-violet-700 dark:bg-violet-400/15 dark:text-violet-300",
    icon: glyph(
      <>
        <path d="m12 3 9 4.8-9 4.8-9-4.8L12 3Z" />
        <path d="m3 12.6 9 4.8 9-4.8" />
      </>,
    ),
  },
};

const formatBytes = (b: number): string => {
  if (b <= 0) return "—";
  const units = ["B", "KB", "MB", "GB", "TB"];
  const i = Math.min(Math.floor(Math.log(b) / Math.log(1024)), units.length - 1);
  const v = b / Math.pow(1024, i);
  return `${v >= 10 || i === 0 ? Math.round(v) : v.toFixed(1)} ${units[i]}`;
};

export default function FilemgrList() {
  const reduce = useReducedMotion();
  const uid = useId();

  const [record, setRecord] = useState<Record<string, FileNode>>(TREE);
  const [path, setPath] = useState<string[]>(["root"]);
  const [selected, setSelected] = useState<Set<string>>(() => new Set());
  const [starred, setStarred] = useState<Set<string>>(
    () => new Set(["guide", "roadmap", "c-northwind"]),
  );
  const [starredOnly, setStarredOnly] = useState(false);
  const [dense, setDense] = useState(false);
  const [query, setQuery] = useState("");
  const [sortKey, setSortKey] = useState<SortKey>("name");
  const [sortDir, setSortDir] = useState<SortDir>("asc");
  const [status, setStatus] = useState("");
  const [undoSnapshot, setUndoSnapshot] = useState<{
    record: Record<string, FileNode>;
    count: number;
  } | null>(null);

  const nameRefs = useRef<(HTMLButtonElement | null)[]>([]);
  const allRef = useRef<HTMLInputElement | null>(null);

  const currentId = path[path.length - 1];
  const currentNode = record[currentId];

  useEffect(() => {
    setPath((prev) => {
      const trimmed = prev.filter((id) => record[id]);
      if (trimmed.length === prev.length) return prev;
      return trimmed.length > 0 ? trimmed : ["root"];
    });
  }, [record]);

  const sizeOf = (n: FileNode): number =>
    n.kind === "folder"
      ? (n.children ?? []).reduce<number>((acc, id) => {
          const child = record[id];
          return child ? acc + sizeOf(child) : acc;
        }, 0)
      : n.size;

  const children: FileNode[] = (currentNode?.children ?? [])
    .map((id) => record[id])
    .filter((n): n is FileNode => Boolean(n));

  const q = query.trim().toLowerCase();
  const filtered = children.filter((n) => {
    if (starredOnly && !starred.has(n.id)) return false;
    if (q.length > 0 && !n.name.toLowerCase().includes(q)) return false;
    return true;
  });

  const visible = [...filtered].sort((a, b) => {
    if (a.kind === "folder" && b.kind !== "folder") return -1;
    if (b.kind === "folder" && a.kind !== "folder") return 1;
    let d = 0;
    if (sortKey === "name") d = a.name.localeCompare(b.name);
    else if (sortKey === "owner") d = a.owner.localeCompare(b.owner);
    else if (sortKey === "size") d = sizeOf(a) - sizeOf(b);
    else d = a.modified - b.modified;
    if (d === 0) d = a.name.localeCompare(b.name);
    return sortDir === "asc" ? d : -d;
  });

  const selectedHere = visible.filter((n) => selected.has(n.id));
  const allChecked = visible.length > 0 && selectedHere.length === visible.length;
  const someChecked = selectedHere.length > 0 && !allChecked;

  useEffect(() => {
    if (allRef.current) allRef.current.indeterminate = someChecked;
  }, [someChecked]);

  const used = Object.values(record).reduce<number>(
    (acc, n) => (n.kind === "folder" ? acc : acc + n.size),
    0,
  );
  const usedPct = Math.min(100, (used / CAPACITY) * 100);

  const open = (node: FileNode) => {
    if (node.kind !== "folder") return;
    setPath((prev) => [...prev, node.id]);
    setSelected(new Set());
    setQuery("");
    setStatus(`Opened folder ${node.name}`);
  };

  const goTo = (index: number) => {
    setPath((prev) => prev.slice(0, index + 1));
    setSelected(new Set());
    setQuery("");
  };

  const toggleSelect = (id: string) => {
    const next = new Set(selected);
    if (next.has(id)) next.delete(id);
    else next.add(id);
    setSelected(next);
    setStatus(`${next.size} item${next.size === 1 ? "" : "s"} selected`);
  };

  const toggleAll = () => {
    const next = new Set(selected);
    if (allChecked) visible.forEach((n) => next.delete(n.id));
    else visible.forEach((n) => next.add(n.id));
    setSelected(next);
    setStatus(`${next.size} item${next.size === 1 ? "" : "s"} selected`);
  };

  const toggleStar = (id: string, name: string) => {
    const next = new Set(starred);
    if (next.has(id)) {
      next.delete(id);
      setStatus(`Removed ${name} from starred`);
    } else {
      next.add(id);
      setStatus(`Starred ${name}`);
    }
    setStarred(next);
  };

  const starSelection = () => {
    if (selected.size === 0) return;
    const next = new Set(starred);
    selected.forEach((id) => next.add(id));
    setStarred(next);
    setStatus(`Starred ${selected.size} item${selected.size === 1 ? "" : "s"}`);
  };

  const removeSelection = () => {
    if (selected.size === 0) return;
    const snapshot = record;
    const doomed = new Set<string>();
    const collect = (id: string) => {
      if (doomed.has(id)) return;
      doomed.add(id);
      (record[id]?.children ?? []).forEach(collect);
    };
    selected.forEach(collect);

    const next: Record<string, FileNode> = {};
    Object.keys(record).forEach((key) => {
      if (doomed.has(key)) return;
      const node = record[key];
      next[key] = node.children
        ? { ...node, children: node.children.filter((c) => !doomed.has(c)) }
        : node;
    });

    const count = selected.size;
    setUndoSnapshot({ record: snapshot, count });
    setRecord(next);
    setSelected(new Set());
    setStatus(`Moved ${count} item${count === 1 ? "" : "s"} to Trash`);
  };

  const undo = () => {
    if (!undoSnapshot) return;
    setRecord(undoSnapshot.record);
    setStatus(
      `Restored ${undoSnapshot.count} item${undoSnapshot.count === 1 ? "" : "s"} from Trash`,
    );
    setUndoSnapshot(null);
  };

  const sortBy = (key: SortKey) => {
    if (key === sortKey) {
      const dir: SortDir = sortDir === "asc" ? "desc" : "asc";
      setSortDir(dir);
      setStatus(`Sorted by ${key}, ${dir === "asc" ? "ascending" : "descending"}`);
    } else {
      setSortKey(key);
      setSortDir("asc");
      setStatus(`Sorted by ${key}, ascending`);
    }
  };

  const ariaSortFor = (key: SortKey): "none" | "ascending" | "descending" =>
    sortKey !== key ? "none" : sortDir === "asc" ? "ascending" : "descending";

  const onRowKeyDown = (e: KeyboardEvent<HTMLButtonElement>, i: number) => {
    const last = visible.length - 1;
    let target = -1;
    if (e.key === "ArrowDown") target = i === last ? 0 : i + 1;
    else if (e.key === "ArrowUp") target = i === 0 ? last : i - 1;
    else if (e.key === "Home") target = 0;
    else if (e.key === "End") target = last;
    else if (e.key === "Backspace" && path.length > 1) {
      e.preventDefault();
      goTo(path.length - 2);
      return;
    }
    if (target >= 0) {
      e.preventDefault();
      nameRefs.current[target]?.focus();
    }
  };

  const ring =
    "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-slate-950";
  const cellY = dense ? "py-1.5" : "py-3";
  const headBtn =
    "group inline-flex items-center gap-1.5 rounded-md px-1.5 py-1 text-xs font-semibold uppercase tracking-wider text-slate-500 transition-colors hover:text-slate-900 dark:text-slate-400 dark:hover:text-slate-100 " +
    ring;

  return (
    <section className="relative w-full bg-slate-50 px-4 py-16 sm:px-6 sm:py-20 dark:bg-slate-950">
      <style>{`
        @keyframes filemgrList-sheen {
          0% { transform: translateX(-110%); }
          100% { transform: translateX(320%); }
        }
        @keyframes filemgrList-pop {
          0% { transform: scale(0.6); opacity: 0; }
          60% { transform: scale(1.12); opacity: 1; }
          100% { transform: scale(1); opacity: 1; }
        }
        .filemgrList-sheen { animation: filemgrList-sheen 2.8s ease-in-out infinite; }
        .filemgrList-pop { animation: filemgrList-pop 260ms cubic-bezier(0.34, 1.56, 0.64, 1) both; }
        @media (prefers-reduced-motion: reduce) {
          .filemgrList-sheen, .filemgrList-pop { animation: none !important; }
        }
      `}</style>

      <div className="mx-auto w-full max-w-5xl">
        <div className="overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-sm shadow-slate-900/5 dark:border-slate-800 dark:bg-slate-900">
          {/* Header */}
          <div className="flex flex-col gap-4 border-b border-slate-200 px-4 py-4 sm:px-6 dark:border-slate-800">
            <div className="flex flex-wrap items-center justify-between gap-3">
              <div className="flex items-center gap-3">
                <span className="flex h-9 w-9 items-center justify-center rounded-xl bg-indigo-600 text-white">
                  <svg
                    viewBox="0 0 24 24"
                    fill="none"
                    stroke="currentColor"
                    strokeWidth={1.7}
                    strokeLinecap="round"
                    strokeLinejoin="round"
                    className="h-[18px] w-[18px]"
                    aria-hidden="true"
                  >
                    <path d="M4 8.5 8 3.5h8l4 5" />
                    <path d="M4 8.5h16v9a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2v-9Z" />
                    <path d="M9.5 13h5" />
                  </svg>
                </span>
                <div>
                  <h2 className="text-base font-semibold text-slate-900 dark:text-slate-50">
                    Innoventix Drive
                  </h2>
                  <p className="text-xs text-slate-500 dark:text-slate-400">
                    Shared workspace · 5 collaborators
                  </p>
                </div>
              </div>

              <div className="flex items-center gap-2">
                <div className="relative">
                  <label htmlFor={`${uid}-search`} className="sr-only">
                    Search in {currentNode?.name ?? "this folder"}
                  </label>
                  <svg
                    viewBox="0 0 24 24"
                    fill="none"
                    stroke="currentColor"
                    strokeWidth={1.7}
                    strokeLinecap="round"
                    className="pointer-events-none absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400"
                    aria-hidden="true"
                  >
                    <circle cx="11" cy="11" r="7" />
                    <path d="m20 20-3.5-3.5" />
                  </svg>
                  <input
                    id={`${uid}-search`}
                    type="search"
                    value={query}
                    onChange={(e) => setQuery(e.target.value)}
                    placeholder="Search this folder"
                    className={`h-9 w-40 rounded-lg border border-slate-200 bg-slate-50 pl-8 pr-3 text-sm text-slate-900 placeholder:text-slate-400 sm:w-56 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-100 dark:placeholder:text-slate-500 ${ring}`}
                  />
                </div>
                <button
                  type="button"
                  onClick={() => setDense((d) => !d)}
                  aria-pressed={dense}
                  className={`flex h-9 w-9 items-center justify-center rounded-lg border text-slate-600 transition-colors dark:text-slate-300 ${ring} ${
                    dense
                      ? "border-indigo-500 bg-indigo-50 text-indigo-700 dark:border-indigo-400/60 dark:bg-indigo-500/15 dark:text-indigo-300"
                      : "border-slate-200 bg-white hover:bg-slate-50 dark:border-slate-700 dark:bg-slate-800 dark:hover:bg-slate-700"
                  }`}
                  title="Compact rows"
                >
                  <span className="sr-only">Compact rows</span>
                  <svg
                    viewBox="0 0 24 24"
                    fill="none"
                    stroke="currentColor"
                    strokeWidth={1.7}
                    strokeLinecap="round"
                    className="h-[18px] w-[18px]"
                    aria-hidden="true"
                  >
                    <path d="M4 7h16M4 12h16M4 17h16" />
                  </svg>
                </button>
              </div>
            </div>

            {/* Breadcrumbs */}
            <nav aria-label="Folder path">
              <ol className="flex flex-wrap items-center gap-1 text-sm">
                {path.map((id, i) => {
                  const node = record[id];
                  if (!node) return null;
                  const isLast = i === path.length - 1;
                  return (
                    <li key={id} className="flex items-center gap-1">
                      {i > 0 && (
                        <svg
                          viewBox="0 0 24 24"
                          fill="none"
                          stroke="currentColor"
                          strokeWidth={1.8}
                          strokeLinecap="round"
                          strokeLinejoin="round"
                          className="h-3.5 w-3.5 text-slate-300 dark:text-slate-600"
                          aria-hidden="true"
                        >
                          <path d="m9 6 6 6-6 6" />
                        </svg>
                      )}
                      {isLast ? (
                        <span
                          aria-current="page"
                          className="rounded-md px-1.5 py-1 font-semibold text-slate-900 dark:text-slate-100"
                        >
                          {node.name}
                        </span>
                      ) : (
                        <button
                          type="button"
                          onClick={() => goTo(i)}
                          className={`rounded-md px-1.5 py-1 text-slate-500 transition-colors hover:bg-slate-100 hover:text-slate-900 dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-slate-100 ${ring}`}
                        >
                          {node.name}
                        </button>
                      )}
                    </li>
                  );
                })}
              </ol>
            </nav>
          </div>

          {/* Toolbar */}
          <div className="flex flex-wrap items-center justify-between gap-3 border-b border-slate-200 bg-slate-50/70 px-4 py-2.5 sm:px-6 dark:border-slate-800 dark:bg-slate-900/60">
            <div className="flex items-center gap-2">
              <button
                type="button"
                onClick={() => setStarredOnly((s) => !s)}
                aria-pressed={starredOnly}
                className={`inline-flex items-center gap-1.5 rounded-lg border px-2.5 py-1.5 text-xs font-medium transition-colors ${ring} ${
                  starredOnly
                    ? "border-amber-400 bg-amber-50 text-amber-800 dark:border-amber-400/60 dark:bg-amber-400/15 dark:text-amber-300"
                    : "border-slate-200 bg-white text-slate-600 hover:bg-slate-100 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-300 dark:hover:bg-slate-700"
                }`}
              >
                <svg
                  viewBox="0 0 24 24"
                  fill={starredOnly ? "currentColor" : "none"}
                  stroke="currentColor"
                  strokeWidth={1.6}
                  strokeLinejoin="round"
                  className="h-3.5 w-3.5"
                  aria-hidden="true"
                >
                  <path d="M12 3.6 14.35 8.36l5.25.77-3.8 3.7.9 5.23L12 15.6l-4.7 2.47.9-5.23-3.8-3.7 5.25-.77L12 3.6Z" />
                </svg>
                Starred only
              </button>
              <span className="text-xs text-slate-500 dark:text-slate-400">
                {visible.length} item{visible.length === 1 ? "" : "s"}
              </span>
            </div>

            <div className="flex items-center gap-2">
              <AnimatePresence initial={false}>
                {selected.size > 0 && (
                  <motion.div
                    key="bulk"
                    initial={{ opacity: 0, x: reduce ? 0 : 8 }}
                    animate={{ opacity: 1, x: 0 }}
                    exit={{ opacity: 0, x: reduce ? 0 : 8 }}
                    transition={{ duration: reduce ? 0 : 0.18 }}
                    className="flex items-center gap-2"
                  >
                    <span
                      key={selected.size}
                      className="filemgrList-pop inline-flex h-6 min-w-6 items-center justify-center rounded-full bg-indigo-600 px-2 text-xs font-semibold text-white"
                    >
                      {selected.size}
                    </span>
                    <button
                      type="button"
                      onClick={starSelection}
                      className={`inline-flex items-center gap-1.5 rounded-lg border border-slate-200 bg-white px-2.5 py-1.5 text-xs font-medium text-slate-700 transition-colors hover:bg-slate-100 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-200 dark:hover:bg-slate-700 ${ring}`}
                    >
                      <svg
                        viewBox="0 0 24 24"
                        fill="none"
                        stroke="currentColor"
                        strokeWidth={1.6}
                        strokeLinejoin="round"
                        className="h-3.5 w-3.5"
                        aria-hidden="true"
                      >
                        <path d="M12 3.6 14.35 8.36l5.25.77-3.8 3.7.9 5.23L12 15.6l-4.7 2.47.9-5.23-3.8-3.7 5.25-.77L12 3.6Z" />
                      </svg>
                      Star
                    </button>
                    <button
                      type="button"
                      onClick={removeSelection}
                      className={`inline-flex items-center gap-1.5 rounded-lg border border-rose-200 bg-rose-50 px-2.5 py-1.5 text-xs font-medium text-rose-700 transition-colors hover:bg-rose-100 dark:border-rose-400/40 dark:bg-rose-500/10 dark:text-rose-300 dark:hover:bg-rose-500/20 ${ring}`}
                    >
                      <svg
                        viewBox="0 0 24 24"
                        fill="none"
                        stroke="currentColor"
                        strokeWidth={1.6}
                        strokeLinecap="round"
                        strokeLinejoin="round"
                        className="h-3.5 w-3.5"
                        aria-hidden="true"
                      >
                        <path d="M4 7h16M9.5 7V4.8h5V7m-8 0 .9 12.2h9.2L17.5 7" />
                      </svg>
                      Move to Trash
                    </button>
                  </motion.div>
                )}
              </AnimatePresence>

              <AnimatePresence initial={false}>
                {undoSnapshot && (
                  <motion.button
                    key="undo"
                    type="button"
                    onClick={undo}
                    initial={{ opacity: 0, scale: reduce ? 1 : 0.94 }}
                    animate={{ opacity: 1, scale: 1 }}
                    exit={{ opacity: 0, scale: reduce ? 1 : 0.94 }}
                    transition={{ duration: reduce ? 0 : 0.18 }}
                    className={`inline-flex items-center gap-1.5 rounded-lg border border-slate-300 bg-white px-2.5 py-1.5 text-xs font-medium text-slate-700 transition-colors hover:bg-slate-100 dark:border-slate-600 dark:bg-slate-800 dark:text-slate-200 dark:hover:bg-slate-700 ${ring}`}
                  >
                    <svg
                      viewBox="0 0 24 24"
                      fill="none"
                      stroke="currentColor"
                      strokeWidth={1.7}
                      strokeLinecap="round"
                      strokeLinejoin="round"
                      className="h-3.5 w-3.5"
                      aria-hidden="true"
                    >
                      <path d="m9 14-5-5 5-5" />
                      <path d="M4 9h9a6 6 0 0 1 0 12h-2.5" />
                    </svg>
                    Undo ({undoSnapshot.count})
                  </motion.button>
                )}
              </AnimatePresence>
            </div>
          </div>

          {/* Table */}
          <div className="overflow-x-auto">
            <table className="w-full min-w-[560px] border-collapse text-left text-sm">
              <caption className="sr-only">
                Files and folders in {currentNode?.name ?? "this folder"}. Use the column
                header buttons to sort, and the arrow keys to move between rows.
              </caption>
              <thead>
                <tr className="border-b border-slate-200 dark:border-slate-800">
                  <th scope="col" className="w-10 pl-4 pr-0 sm:pl-6">
                    <input
                      ref={allRef}
                      type="checkbox"
                      checked={allChecked}
                      onChange={toggleAll}
                      disabled={visible.length === 0}
                      aria-label={
                        allChecked ? "Deselect all items" : "Select all items in this folder"
                      }
                      className={`h-4 w-4 cursor-pointer rounded border-slate-300 accent-indigo-600 disabled:cursor-not-allowed disabled:opacity-40 dark:border-slate-600 ${ring}`}
                    />
                  </th>
                  <th scope="col" aria-sort={ariaSortFor("name")} className="py-2.5 pl-3 pr-3">
                    <SortHeader
                      label="Name"
                      active={sortKey === "name"}
                      dir={sortDir}
                      onClick={() => sortBy("name")}
                      className={headBtn}
                    />
                  </th>
                  <th
                    scope="col"
                    aria-sort={ariaSortFor("owner")}
                    className="hidden py-2.5 pr-3 md:table-cell"
                  >
                    <SortHeader
                      label="Owner"
                      active={sortKey === "owner"}
                      dir={sortDir}
                      onClick={() => sortBy("owner")}
                      className={headBtn}
                    />
                  </th>
                  <th
                    scope="col"
                    aria-sort={ariaSortFor("modified")}
                    className="hidden py-2.5 pr-3 sm:table-cell"
                  >
                    <SortHeader
                      label="Modified"
                      active={sortKey === "modified"}
                      dir={sortDir}
                      onClick={() => sortBy("modified")}
                      className={headBtn}
                    />
                  </th>
                  <th scope="col" aria-sort={ariaSortFor("size")} className="py-2.5 pr-3">
                    <SortHeader
                      label="Size"
                      active={sortKey === "size"}
                      dir={sortDir}
                      onClick={() => sortBy("size")}
                      className={headBtn}
                    />
                  </th>
                  <th scope="col" className="w-12 py-2.5 pr-4 sm:pr-6">
                    <span className="sr-only">Star</span>
                  </th>
                </tr>
              </thead>
              <tbody>
                {visible.map((node, i) => {
                  const meta = KIND_META[node.kind];
                  const isSelected = selected.has(node.id);
                  const isStarred = starred.has(node.id);
                  const count = node.children?.length ?? 0;
                  return (
                    <motion.tr
                      key={node.id}
                      initial={{ opacity: 0, y: reduce ? 0 : 5 }}
                      animate={{ opacity: 1, y: 0 }}
                      transition={{
                        duration: reduce ? 0 : 0.22,
                        delay: reduce ? 0 : Math.min(i * 0.025, 0.2),
                      }}
                      className={`border-b border-slate-100 transition-colors last:border-0 dark:border-slate-800/70 ${
                        isSelected
                          ? "bg-indigo-50/70 dark:bg-indigo-500/10"
                          : "hover:bg-slate-50 dark:hover:bg-slate-800/50"
                      }`}
                    >
                      <td className={`${cellY} pl-4 pr-0 align-middle sm:pl-6`}>
                        <input
                          type="checkbox"
                          checked={isSelected}
                          onChange={() => toggleSelect(node.id)}
                          aria-label={`Select ${node.name}`}
                          className={`h-4 w-4 cursor-pointer rounded border-slate-300 accent-indigo-600 dark:border-slate-600 ${ring}`}
                        />
                      </td>
                      <td className={`${cellY} pl-3 pr-3 align-middle`}>
                        <button
                          type="button"
                          ref={(el) => {
                            nameRefs.current[i] = el;
                          }}
                          onClick={() =>
                            node.kind === "folder" ? open(node) : toggleSelect(node.id)
                          }
                          onKeyDown={(e) => onRowKeyDown(e, i)}
                          className={`flex w-full items-center gap-3 rounded-lg py-0.5 pr-2 text-left ${ring}`}
                        >
                          <span
                            className={`flex shrink-0 items-center justify-center rounded-lg ${meta.tone} ${
                              dense ? "h-7 w-7" : "h-9 w-9"
                            }`}
                          >
                            {meta.icon}
                          </span>
                          <span className="min-w-0">
                            <span className="block truncate font-medium text-slate-900 dark:text-slate-100">
                              {node.name}
                            </span>
                            <span className="block truncate text-xs text-slate-500 dark:text-slate-400">
                              {node.kind === "folder"
                                ? `${count} item${count === 1 ? "" : "s"}`
                                : meta.label}
                              <span className="sm:hidden"> · {node.modifiedLabel}</span>
                            </span>
                          </span>
                          {node.kind === "folder" && (
                            <svg
                              viewBox="0 0 24 24"
                              fill="none"
                              stroke="currentColor"
                              strokeWidth={1.8}
                              strokeLinecap="round"
                              strokeLinejoin="round"
                              className="ml-auto h-4 w-4 shrink-0 text-slate-300 dark:text-slate-600"
                              aria-hidden="true"
                            >
                              <path d="m9 6 6 6-6 6" />
                            </svg>
                          )}
                        </button>
                      </td>
                      <td
                        className={`${cellY} hidden pr-3 align-middle text-slate-600 md:table-cell dark:text-slate-400`}
                      >
                        {node.owner}
                      </td>
                      <td
                        className={`${cellY} hidden whitespace-nowrap pr-3 align-middle text-slate-600 sm:table-cell dark:text-slate-400`}
                      >
                        {node.modifiedLabel}
                      </td>
                      <td
                        className={`${cellY} whitespace-nowrap pr-3 align-middle tabular-nums text-slate-600 dark:text-slate-400`}
                      >
                        {formatBytes(sizeOf(node))}
                      </td>
                      <td className={`${cellY} pr-4 align-middle sm:pr-6`}>
                        <button
                          type="button"
                          onClick={() => toggleStar(node.id, node.name)}
                          aria-pressed={isStarred}
                          className={`flex h-8 w-8 items-center justify-center rounded-lg transition-colors hover:bg-slate-100 dark:hover:bg-slate-700 ${ring} ${
                            isStarred
                              ? "text-amber-500 dark:text-amber-400"
                              : "text-slate-300 dark:text-slate-600"
                          }`}
                        >
                          <span className="sr-only">
                            {isStarred ? `Unstar ${node.name}` : `Star ${node.name}`}
                          </span>
                          <svg
                            viewBox="0 0 24 24"
                            fill={isStarred ? "currentColor" : "none"}
                            stroke="currentColor"
                            strokeWidth={1.6}
                            strokeLinejoin="round"
                            className="h-4 w-4"
                            aria-hidden="true"
                          >
                            <path d="M12 3.6 14.35 8.36l5.25.77-3.8 3.7.9 5.23L12 15.6l-4.7 2.47.9-5.23-3.8-3.7 5.25-.77L12 3.6Z" />
                          </svg>
                        </button>
                      </td>
                    </motion.tr>
                  );
                })}

                {visible.length === 0 && (
                  <tr>
                    <td colSpan={6} className="px-6 py-14 text-center">
                      <p className="text-sm font-medium text-slate-900 dark:text-slate-100">
                        {q.length > 0 || starredOnly
                          ? "Nothing matches those filters"
                          : "This folder is empty"}
                      </p>
                      <p className="mx-auto mt-1 max-w-xs text-xs text-slate-500 dark:text-slate-400">
                        {q.length > 0 || starredOnly
                          ? "Try a different search term, or turn off the starred filter."
                          : "Drop files here or move something in from another folder."}
                      </p>
                    </td>
                  </tr>
                )}
              </tbody>
            </table>
          </div>

          {/* Footer */}
          <div className="flex flex-wrap items-center justify-between gap-3 border-t border-slate-200 px-4 py-3.5 sm:px-6 dark:border-slate-800">
            <div className="min-w-0 flex-1">
              <div className="flex items-baseline justify-between gap-3">
                <span className="text-xs font-medium text-slate-600 dark:text-slate-300">
                  Storage
                </span>
                <span className="text-xs tabular-nums text-slate-500 dark:text-slate-400">
                  {formatBytes(used)} of {formatBytes(CAPACITY)}
                </span>
              </div>
              <div className="mt-1.5 h-1.5 w-full max-w-sm overflow-hidden rounded-full bg-slate-200 dark:bg-slate-800">
                <motion.div
                  className="relative h-full overflow-hidden rounded-full bg-indigo-600 dark:bg-indigo-500"
                  animate={{ width: `${usedPct}%` }}
                  initial={false}
                  transition={{ duration: reduce ? 0 : 0.45, ease: "easeOut" }}
                >
                  <span className="filemgrList-sheen absolute inset-y-0 left-0 w-6 bg-white/40" />
                </motion.div>
              </div>
            </div>
            <p className="text-xs text-slate-400 dark:text-slate-500">
              Arrow keys move · Backspace goes up
            </p>
          </div>
        </div>

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

function SortHeader({
  label,
  active,
  dir,
  onClick,
  className,
}: {
  label: string;
  active: boolean;
  dir: SortDir;
  onClick: () => void;
  className: string;
}) {
  return (
    <button type="button" onClick={onClick} className={className}>
      {label}
      <svg
        viewBox="0 0 24 24"
        fill="none"
        stroke="currentColor"
        strokeWidth={2}
        strokeLinecap="round"
        strokeLinejoin="round"
        className={`h-3 w-3 transition-all ${
          active
            ? `opacity-100 ${dir === "desc" ? "rotate-180" : ""}`
            : "opacity-0 group-hover:opacity-40"
        }`}
        aria-hidden="true"
      >
        <path d="m6 15 6-6 6 6" />
      </svg>
    </button>
  );
}

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 →