File Manager Grid
Original · freefile manager grid 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-grid.json"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 “{query}”</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
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 List
Originalfile manager list 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.

