File Manager List
Original · freefile manager list view
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"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
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 quoteSimilar components
Browse all →
File Manager Grid
Originalfile manager grid view

File Manager Upload
Originalupload dropzone with progress

File Manager Breadcrumb
Originalfile path breadcrumb with toolbar

File Manager Tree
Originalfolder tree sidebar

File Manager Preview
Originalfile preview panel

File Manager Storage
Originalstorage usage widget

File Manager Recent
Originalrecent files list

Spotlight Hero
OriginalA centred hero with a soft radial spotlight, badge and dual call-to-action.

Split Hero
OriginalA two-column hero pairing a headline and CTAs with a product mock and social proof.

Gradient Spotlight Hero
OriginalA minimal centred hero with a soft gradient-mesh backdrop, announcement pill and dual call-to-action buttons.

App Preview Hero
OriginalA centred hero that pairs headline copy with a realistic product dashboard mock built entirely from markup, complete with browser chrome and a floating notification card.

Waitlist Capture Hero
OriginalA dark, focused hero with an inline email capture form and avatar social proof, ready for pre-launch waitlists.

