File Manager UI Design: Grid/List Toggle, Breadcrumbs, Drag Upload
Build a complete file manager UI in React — grid/list toggle, breadcrumb navigation, drag-and-drop upload, and context menus that actually feel good.
Why File Managers Are Harder Than They Look
Most devs underestimate this component. You see 'file manager' on the ticket, think 'oh, just a list with icons,' and two weeks later you're debugging drag-and-drop edge cases at 11pm with three browser tabs open to MDN. It's deceptively complex.
The core problem is state. A file manager isn't just UI — it's a mini operating system metaphor inside your app. You need to track the current directory path, the selection state (single vs multi), the view mode (grid or list), upload progress for potentially dozens of concurrent files, and the hover state of a drop zone that can be nested inside other draggable things. That's a lot for one component tree.
Honestly, the 2024–2026 generation of cloud storage UIs — Dropbox, Google Drive's 2025 redesign, Linear's attachment panel — have raised the bar considerably. Users now expect 48px touch targets, keyboard navigation, right-click context menus, and drag-to-rearrange. If you're building an admin panel, a SaaS product, or a CMS, you need to match that bar.
This article walks you through building the real thing: a grid/list toggle that remembers its state, breadcrumb navigation that doesn't break on deep paths, a drop zone with visual feedback that won't fight with the browser's default drag behavior, and a context menu that positions itself correctly at viewport edges. We're going full production.
The Layout Foundation: Sidebar + Main Panel
Start with a two-column layout. The sidebar holds your folder tree (optional but common in desktop-style UIs), and the main panel holds the toolbar, breadcrumbs, and the file grid or list. Don't nest the entire thing in a single scrollable container — keep the sidebar fixed and let the main panel scroll independently. This is the part most tutorials skip and then your sidebar scrolls away mid-use.
Here's the outer shell in React with Tailwind. Keep it simple — the complexity lives in the children:
``tsx
export function FileManagerLayout({ children }: { children: React.ReactNode }) {
return (
<div className="flex h-screen overflow-hidden bg-zinc-950">
<aside className="w-56 shrink-0 border-r border-white/10 overflow-y-auto">
<FolderTree />
</aside>
<main className="flex flex-col flex-1 overflow-hidden">
<Toolbar />
<Breadcrumbs />
<div className="flex-1 overflow-y-auto p-4">{children}</div>
</main>
</div>
);
}
``
Worth noting: overflow-hidden on the outer shell is load-bearing. Without it, drag events near the viewport edge will cause the whole page to scroll, which feels broken. Set it early, not as a hotfix.
On mobile (below 768px), collapse the sidebar into a slide-out drawer triggered by a hamburger button. You can use a simple useState boolean for this — no library needed. The folder tree inside the drawer should close automatically after a folder is selected, which saves the user an extra tap every single time.
Breadcrumb Navigation That Actually Works
Breadcrumbs for a file manager are different from route breadcrumbs. You're managing virtual paths, not actual URL segments, and the path can be deep — 8 or 10 levels isn't unusual in enterprise file trees. You need truncation with an ellipsis dropdown for long paths, and each crumb needs to be clickable to jump directly to that level.
Store the path as an array of { id, name } objects. This makes it trivial to truncate, re-render, and navigate:
``tsx
type PathSegment = { id: string; name: string };
function Breadcrumbs({
path,
onNavigate,
}: {
path: PathSegment[];
onNavigate: (index: number) => void;
}) {
const MAX_VISIBLE = 3;
const isLong = path.length > MAX_VISIBLE + 1;
const head = path[0];
const tail = path.slice(-MAX_VISIBLE);
const hidden = isLong ? path.slice(1, path.length - MAX_VISIBLE) : [];
return (
<nav className="flex items-center gap-1 px-4 py-2 text-sm text-zinc-400">
<button onClick={() => onNavigate(0)} className="hover:text-white">
{head.name}
</button>
{isLong && (
<EllipsisDropdown items={hidden} onSelect={(i) => onNavigate(i + 1)} />
)}
{tail.map((seg, i) => {
const globalIndex = isLong ? path.length - MAX_VISIBLE + i : i + 1;
return (
<>
<span className="text-zinc-600">/</span>
<button
key={seg.id}
onClick={() => onNavigate(globalIndex)}
className="hover:text-white disabled:text-white disabled:cursor-default"
disabled={globalIndex === path.length - 1}
>
{seg.name}
</button>
</>
);
})}
</nav>
);
}
``
The disabled on the last crumb is intentional — it's the current folder, clicking it does nothing, and the visual distinction (white vs zinc-400) communicates that without needing an icon. In practice, users click breadcrumbs to go *up*, never to re-enter where they already are.
One more thing — add a Home icon before the root segment if you're inside an app where 'root' isn't meaningful on its own. For a Google Drive-style product where root is 'My Files', skip the icon and just use the text. Keep the pattern consistent with whatever the rest of your nav does.
Grid/List Toggle: State, Animation, Layout Switch
This is the fun part. The toggle itself is dead simple — one bit of state — but the transition between layouts is where you can add real polish. A layout-aware component uses CSS Grid for the grid view and a flex column for the list view, and the transition between them should animate opacity and scale, not width/height (those are expensive and janky).
Persist the preference in localStorage. Users choose a view mode once and expect it to stick:
``tsx
function useViewMode() {
const [mode, setMode] = React.useState<'grid' | 'list'>(() => {
return (localStorage.getItem('fm-view') as 'grid' | 'list') ?? 'grid';
});
const toggle = (next: 'grid' | 'list') => {
setMode(next);
localStorage.setItem('fm-view', next);
};
return { mode, toggle };
}
``
For the file grid itself, use grid-cols-[repeat(auto-fill,minmax(120px,1fr))] in grid mode and a divide-y divide-white/5 column in list mode. Don't use a fixed column count — auto-fill with a min of 120px adapts to the panel width automatically, which matters when the sidebar is open vs. collapsed.
``tsx
<div
className={cn(
mode === 'grid'
? 'grid grid-cols-[repeat(auto-fill,minmax(120px,1fr))] gap-3'
: 'flex flex-col divide-y divide-white/5',
)}
>
{files.map((f) => (
<FileCard key={f.id} file={f} mode={mode} />
))}
</div>
``
Quick aside: in list mode, show columns — name, size, modified date, kind. In grid mode, show just the thumbnail and truncated filename. Your FileCard component should accept mode as a prop and render the right layout. Don't make two separate components for this; the conditional rendering is minimal and the shared selection/drag logic isn't worth duplicating.
Look, the toggle button itself should use icons (Grid2x2 and List from Lucide), not text labels. At 16x16px they read perfectly and take up far less toolbar space. Wrap them in a segmented control — two buttons with a shared border — and highlight the active one with a bg-white/10 background. That's it. You can see similar segmented patterns in the component examples across Empire UI if you want a jump-start.
Drag-and-Drop File Upload That Doesn't Drive Users Nuts
The native HTML5 drag API is notoriously tricky. The biggest gotcha: dragenter and dragleave fire when you hover over a *child element* of your drop zone, not just the zone boundary. This means your 'drag active' highlight flickers like a broken fluorescent light unless you debounce or count nesting depth.
The counter pattern is the cleanest fix:
``tsx
function useDropZone(onDrop: (files: File[]) => void) {
const [isDragging, setIsDragging] = React.useState(false);
const counter = React.useRef(0);
const onDragEnter = (e: React.DragEvent) => {
e.preventDefault();
counter.current++;
setIsDragging(true);
};
const onDragLeave = () => {
counter.current--;
if (counter.current === 0) setIsDragging(false);
};
const onDragOver = (e: React.DragEvent) => e.preventDefault();
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
counter.current = 0;
setIsDragging(false);
const files = Array.from(e.dataTransfer.files);
onDrop(files);
};
return { isDragging, onDragEnter, onDragLeave, onDragOver, onDrop: handleDrop };
}
``
Apply isDragging to give the drop zone a 2px dashed border in ring-2 ring-blue-500/60 and a subtle bg-blue-500/5 background. The 60% opacity on the ring keeps it visible without screaming at the user. Add a centered text overlay — 'Drop files here' — that fades in with opacity-0 group-[.is-dragging]:opacity-100.
For upload progress, you'd typically fire off individual fetch requests (or use a queue library like p-queue with concurrency 3) and track { id, name, progress, status } per file. Render a small progress bar below each file card that fades out after completion. Don't block the whole UI while files upload — users should be able to keep browsing while things upload in the background.
One edge case worth handling: when the user drops a folder, e.dataTransfer.files only gives you the files at the root level in most browsers as of 2026. To traverse subdirectories you need the webkitGetAsEntry() API. It's ugly but it works in Chrome, Edge, and Firefox 91+. If you need full folder support, budget an extra half-day for that recursion logic.
Context Menu, Multi-Select, and Keyboard Shortcuts
Right-click context menus make a file manager feel native. The tricky part is positioning — you need to detect viewport edges and flip the menu above or to the left if it would overflow. Render the menu in a portal (createPortal into document.body) to avoid z-index and overflow clipping issues from parent containers.
``tsx
function useContextMenu() {
const [menu, setMenu] = React.useState<{ x: number; y: number; fileId: string } | null>(null);
const open = (e: React.MouseEvent, fileId: string) => {
e.preventDefault();
const MENU_W = 180;
const MENU_H = 220;
const x = e.clientX + MENU_W > window.innerWidth ? e.clientX - MENU_W : e.clientX;
const y = e.clientY + MENU_H > window.innerHeight ? e.clientY - MENU_H : e.clientY;
setMenu({ x, y, fileId });
};
const close = () => setMenu(null);
return { menu, open, close };
}
``
Multi-select with shift-click and cmd/ctrl-click is expected behavior. Track selected file IDs in a Set<string>. For shift-click, you need the last-clicked index so you can select a range — store that in a ref alongside the set. It's about 30 lines of logic but it's the difference between a toy and a tool.
Keyboard shortcuts: Delete to move to trash, Enter to open/rename, Escape to deselect, Cmd+A to select all, arrow keys to move focus between files in grid mode. The arrow key navigation in grid mode requires knowing how many columns are rendered, which you can get from a ResizeObserver watching the grid container and dividing its width by the column min-width (120px here).
The visual style for your file manager can pull from whatever design language your app uses. If you're building something dark and technical, the cyberpunk component set on Empire UI has icon cards and panels that adapt well. If you're going for a clean cloud-storage aesthetic, the glassmorphism components give you frosted panel backgrounds that make folder cards look sharp without a lot of extra CSS.
Putting It Together: Performance and Production Concerns
Virtualization matters once you hit 200+ files in a folder. Use @tanstack/react-virtual (formerly react-virtual) to render only the visible rows. In grid mode this is slightly more complex since you're dealing with a variable number of columns per row — use useVirtualizer with a lanes option equal to your computed column count.
File thumbnails should be lazy-loaded. Use the native loading="lazy" attribute on <img> tags and generate thumbnails server-side for images/PDFs. For non-image file types, map MIME types to icon components rather than fetching anything — a getMimeIcon(mimeType: string): LucideIcon lookup table is 20 lines and covers 95% of common types.
Honestly, the most underrated optimization is debouncing your selection state updates. If you're syncing selected files to a URL param or to a parent component that triggers side effects, a 16ms debounce prevents you from firing 30 state updates during a fast shift-click drag-select. It's a tiny change with a noticeable smoothness improvement.
For the overall design, explore the box shadow generator to craft the right elevation for your file cards — a light box-shadow: 0 1px 3px rgba(0,0,0,0.4) in dark mode keeps cards visually separated without the heavy outlines that make grid layouts feel crowded. Subtle shadow is better than a border 90% of the time in dark UIs.
FAQ
react-dropzone is solid and handles the counter pattern and folder drops for you, so use it if you're on a deadline. If you need a very specific UX (like dropping into nested folder targets simultaneously) you'll hit its limits quickly and end up patching internals — at that point, roll your own with the counter approach shown above.
Each folder target needs its own drop zone with a distinct ID. On drop, fire a move action with { fileIds: selectedIds, targetFolderId }. The tricky part is distinguishing 'drop from desktop' (new upload) vs 'drop from inside manager' (move) — check e.dataTransfer.types for 'Files' vs a custom MIME type you set on dragstart from internal cards.
A Set<string> of expanded folder IDs stored in component state works fine for most cases. If you need persistence across sessions, serialize it to localStorage. For trees deeper than 4-5 levels, consider lazy-loading children on expand rather than loading the whole tree upfront — your backend will thank you.
Focus management is the hard part. Use tabIndex={0} on file cards and handle onKeyDown for Enter (open), Delete (trash), and arrow keys (move focus). The context menu should open on the Menu key as well as right-click. Test with a screen reader — VoiceOver on macOS is free and catches most issues in an afternoon.