Skip to content
ilinxa/pro-ui

File Manager

alphav0.1.3

Finder-style file browser — grid and list views, marquee multi-select, cut copy paste, drag-and-drop, and a shared clipboard primitive.

Category: NavigationUpdated: 2026-08-11Created: 2026-05-10Author: ilinxa

Context

Pairs with `file-tree` (the sidebar primitive) for the dual-pane Finder layout — drop `<FileTree>` into `<FileManager>`'s `sidebar` slot. Use anywhere a current-folder content view is needed: asset libraries, document workspaces, attachment managers, S3-bucket explorers. Controlled-data, object-shape callbacks, lazy children. The new `<FileClipboardProvider>` syncs cut/copy/paste across multiple instances.

Installation

Initialize shadcn (once per project)Seeds lib/utils.ts and components.json. Skip if you've already used any shadcn component.
pnpm dlx shadcn@latest init
Install the component
pnpm dlx shadcn@latest add @ilinxa/file-manager

Add -fixtures for dummy data:

pnpm dlx shadcn@latest add @ilinxa/file-manager-fixtures

CLI can't resolve @ilinxa? The namespace is listed in the official shadcn registry directory, so current CLIs need no configuration. If yours can't resolve it (older or pinned versions, self-hosted mirrors), register it manually in components.json:

"registries": {
  "@ilinxa": "https://ui.ilinxa.com/r/{name}.json"
}

Preview

public
src
next.config.ts
package.json
README.md
tsconfig.json
6 items6.3 KB total

Demo source

demo.tsxtsx
"use client"; import { useCallback, useState } from "react";import { Tabs, TabsContent, TabsTrigger } from "@/components/ui/tabs";import { SwipeTabsList } from "@/components/site/swipe-tabs-list";import { FileManager } from "./file-manager";import {  dummyFsNodes,  dummyFlatGrid,  dummyLargeFolder,} from "./dummy-data";import { mergeLoadedChildren } from "./lib/tree-utils";import { FileClipboardProvider } from "../_shared/file-clipboard";import type { FsNode } from "./types"; function Frame({ children }: { children: React.ReactNode }) {  return (    <div className="h-130 w-full overflow-hidden rounded-lg border border-border bg-card">      {children}    </div>  );} function StandaloneGridDemo() {  return (    <Frame>      <FileManager        nodes={dummyFsNodes}        defaultCurrentFolderId={null}        title="Project"      />    </Frame>  );} function FlatGridDemo() {  return (    <Frame>      <FileManager        nodes={dummyFlatGrid}        defaultCurrentFolderId={null}        title="Asset library"        showBackForward={false}        showUpButton={false}        showPathBar={false}      />    </Frame>  );} function FullCrudDemo() {  const [nodes, setNodes] = useState<FsNode[]>(dummyFsNodes);  const [counter, setCounter] = useState(1);  const [currentFolderId, setCurrentFolderId] = useState<string | null>(null);   const handleCreate = useCallback(    ({      parentId,      type,    }: {      parentId: string | null;      type: "file" | "folder";    }) => {      const id = `new-${counter}`;      setCounter((n) => n + 1);      const newNode: FsNode = {        id,        name:          type === "file" ? `untitled-${counter}.txt` : `New Folder ${counter}`,        type,        parentId,        children: type === "folder" ? [] : undefined,      };      const insert = (list: FsNode[]): FsNode[] => {        if (parentId === null) return [...list, newNode];        return list.map((n) => {          if (n.id === parentId) {            return { ...n, children: [...(n.children ?? []), newNode] };          }          if (n.children) return { ...n, children: insert(n.children) };          return n;        });      };      setNodes(insert);    },    [counter],  );   const handleRename = useCallback(    (args: { id: string; nextName: string }) => {      const rename = (list: FsNode[]): FsNode[] =>        list.map((n) => {          if (n.id === args.id) return { ...n, name: args.nextName };          if (n.children) return { ...n, children: rename(n.children) };          return n;        });      setNodes(rename);    },    [],  );   const handleDelete = useCallback((args: { ids: string[] }) => {    const ids = new Set(args.ids);    const remove = (list: FsNode[]): FsNode[] =>      list        .filter((n) => !ids.has(n.id))        .map((n) =>          n.children ? { ...n, children: remove(n.children) } : n,        );    setNodes(remove);  }, []);   const moveAll = useCallback(    (      args: { ids: string[]; targetId: string | null },      collected: FsNode[] = [],    ): FsNode[] => {      const moving = new Set(args.ids);      const collect: FsNode[] = collected;      const removeAndCollect = (list: FsNode[]): FsNode[] =>        list          .filter((n) => {            if (moving.has(n.id)) {              collect.push(n);              return false;            }            return true;          })          .map((n) =>            n.children              ? { ...n, children: removeAndCollect(n.children) }              : n,          );      const removed = removeAndCollect(nodes);      if (args.targetId === null) return [...removed, ...collect];      const insertInside = (list: FsNode[]): FsNode[] =>        list.map((n) => {          if (n.id === args.targetId) {            return { ...n, children: [...(n.children ?? []), ...collect] };          }          if (n.children) return { ...n, children: insertInside(n.children) };          return n;        });      return insertInside(removed);    },    [nodes],  );   const handleMove = useCallback(    (args: { ids: string[]; targetId: string | null }) => {      setNodes(moveAll(args));    },    [moveAll],  );   const handlePaste = useCallback(    (args: {      ids: string[];      kind: "cut" | "copy";      targetFolderId: string | null;    }) => {      if (args.kind === "cut") {        setNodes(moveAll({ ids: args.ids, targetId: args.targetFolderId }));      } else {        // copy: clone with fresh ids        const cloned: FsNode[] = [];        const cloneNode = (n: FsNode): FsNode => ({          ...n,          id: `${n.id}-copy-${counter}`,          parentId: args.targetFolderId,          children: n.children?.map(cloneNode),        });        const collect = (list: FsNode[]) => {          for (const n of list) {            if (args.ids.includes(n.id)) cloned.push(cloneNode(n));            if (n.children) collect(n.children);          }        };        collect(nodes);        setCounter((n) => n + 1);        if (args.targetFolderId === null) {          setNodes((prev) => [...prev, ...cloned]);        } else {          setNodes((prev) =>            prev.map(function attach(n: FsNode): FsNode {              if (n.id === args.targetFolderId) {                return {                  ...n,                  children: [...(n.children ?? []), ...cloned],                };              }              if (n.children) {                return { ...n, children: n.children.map(attach) };              }              return n;            }),          );        }      }    },    [moveAll, nodes, counter],  );   return (    <Frame>      <FileClipboardProvider>        <FileManager          nodes={nodes}          currentFolderId={currentFolderId}          onCurrentFolderChange={({ folderId }) => setCurrentFolderId(folderId)}          title="Full CRUD"          onOpen={({ node }) => alert(`Open: ${node.name}`)}          onCreate={handleCreate}          onRename={handleRename}          onDelete={handleDelete}          onMove={handleMove}          onPaste={handlePaste}          onRefresh={() => alert("Refresh!")}        />      </FileClipboardProvider>    </Frame>  );} function LazyLoadDemo() {  const [nodes, setNodes] = useState<FsNode[]>([    { id: "src", name: "src", type: "folder", parentId: null },    { id: "public", name: "public", type: "folder", parentId: null },    { id: "README.md", name: "README.md", type: "file", parentId: null },  ]);  const [currentFolderId, setCurrentFolderId] = useState<string | null>(null);   const lazyChildren: Record<string, FsNode[]> = {    src: [      { id: "src/app", name: "app", type: "folder", parentId: "src" },      { id: "src/lib", name: "lib", type: "folder", parentId: "src" },      {        id: "src/index.ts",        name: "index.ts",        type: "file",        parentId: "src",      },    ],    "src/app": [      {        id: "src/app/page.tsx",        name: "page.tsx",        type: "file",        parentId: "src/app",      },    ],    "src/lib": [      {        id: "src/lib/utils.ts",        name: "utils.ts",        type: "file",        parentId: "src/lib",      },    ],    public: [      {        id: "public/og.png",        name: "og.png",        type: "file",        parentId: "public",      },    ],  };   return (    <Frame>      <FileManager        nodes={nodes}        currentFolderId={currentFolderId}        onCurrentFolderChange={({ folderId }) => setCurrentFolderId(folderId)}        title="Lazy load"        onLoadChildren={async ({ nodeId }) => {          await new Promise((r) => setTimeout(r, 350));          const kids = lazyChildren[nodeId] ?? [];          setNodes((prev) => mergeLoadedChildren(prev, nodeId, kids));          return kids;        }}      />    </Frame>  );} function VirtualizedDemo() {  return (    <Frame>      <FileManager        nodes={dummyLargeFolder}        defaultCurrentFolderId={null}        defaultViewMode="list"        title="250 items (virtualized)"        showBackForward={false}        showUpButton={false}        showPathBar={false}      />    </Frame>  );} export default function FileManagerDemo() {  return (    <Tabs defaultValue="standalone" className="w-full">      <SwipeTabsList>        <TabsTrigger value="standalone">Standalone</TabsTrigger>        <TabsTrigger value="flat">Flat grid</TabsTrigger>        <TabsTrigger value="crud">Full CRUD + clipboard</TabsTrigger>        <TabsTrigger value="lazy">Lazy load</TabsTrigger>        <TabsTrigger value="virtual">Virtualized list</TabsTrigger>      </SwipeTabsList>      <TabsContent value="standalone">        <StandaloneGridDemo />      </TabsContent>      <TabsContent value="flat">        <FlatGridDemo />      </TabsContent>      <TabsContent value="crud">        <FullCrudDemo />      </TabsContent>      <TabsContent value="lazy">        <LazyLoadDemo />      </TabsContent>      <TabsContent value="virtual">        <VirtualizedDemo />      </TabsContent>    </Tabs>  );} 

Usage

When to use

FileManager is the Mac-Finder content pane: a grid / list view of the current folder with multi-select, cut / copy / paste, drag-and-drop, sort, and view-mode switching. Pair it with file-tree in the sidebar slot for the dual-pane Finder layout. Standalone use is also supported.

Data shape

Same FsNode shape as file-tree — id / name / type / parentId / children / ext / size / modifiedAt / icon / meta. Consumer owns nodes and currentFolderId; manager fires object-shape callbacks on every operation.

Shared clipboard

Wrap one or more <FileManager> instances in <FileClipboardProvider> to sync cut / copy / paste across instances. Without a provider, each manager keeps its own internal clipboard. Controlled mode: pass clipboard + onClipboardChange.

import { FileManager, FileClipboardProvider } from "@/components/file-manager"

<FileClipboardProvider>
  <FileManager nodes={nodes} sidebar={<FileTree nodes={nodes} />} />
</FileClipboardProvider>

Lazy loading

import { mergeLoadedChildren } from "@/components/file-manager"

<FileManager
  nodes={nodes}
  currentFolderId={current}
  onCurrentFolderChange={({ folderId }) => setCurrent(folderId)}
  onLoadChildren={async ({ nodeId }) => {
    const kids = await fs.list(nodeId);
    setNodes((prev) => mergeLoadedChildren(prev, nodeId, kids));
    return kids;
  }}
/>

Keyboard map

  • Arrow keys move focus (2-D nav in grid mode, up/down only in list mode).
  • Enter opens a file (onOpen) or navigates into a folder.
  • Backspace deletes selected items (with confirm), or navigates up to parent if no selection.
  • F2 renames; Delete deletes; Esc clears selection or cancels rename.
  • Cmd/Ctrl+X / C / V cut / copy / paste; Cmd/Ctrl+A select all visible.
  • Cmd/Ctrl+[ back; Cmd/Ctrl+] forward.
  • Type-ahead: typing letters jumps focus to the first matching item name (resets after 800ms).

Drag-and-drop

  • Within the manager: drag selected items onto a folder. Cycle / self-drop refused; drops on files are rejected (only folders are valid targets).
  • From the desktop: drop OS files onto the manager to fire onExternalDrop. targetFolderId is the folder the user dropped on, or the current folder otherwise.
  • Marquee selection: drag a rectangle on empty space to select multiple items. Shift+drag adds to existing selection.

Custom chrome

Replace the toolbar via renderToolbar (typed context), or compose the standalone parts: FileManagerToolbar,FileManagerPathBar, FileManagerViewToggle,FileManagerIconSizeControl, FileManagerSortMenu, FileManagerSearchInput, FileManagerStatusBar. They read from useFileManager().

Gotchas

  • Drops on files are rejected; only folders accept drops. Drops on empty whitespace are no-ops for internal drag, upload-to-current for external drag.
  • Selection clears on navigate by default. Set preserveSelectionOnNavigate={true} to keep it.
  • Cut / copy / paste require onPaste wired. Without a paste handler, the manager fires onClipboardChange but paste does nothing.
  • Dragging files OUT to the desktop is not supported in v0.1.0. Add a Download button via renderContextMenu or the toolbar overflow.
  • List-view virtualizes at virtualizeThreshold items (default 200). Grid view does NOT virtualize at v0.1.0.

Features

  • Grid + list view modes with three icon sizes (sm/md/lg) in grid mode
  • Path bar / breadcrumbs with click-to-edit text input mode
  • Back / Forward / Up navigation with built-in 50-entry history (controllable bypass)
  • Multi-select with Cmd/Ctrl+click, Shift+click range, Cmd/Ctrl+A, plus marquee (drag-rectangle)
  • Cut / copy / paste backed by a shared `<FileClipboardProvider>` primitive
  • Right-click menu (Open / New / Cut / Copy / Paste / Rename / Delete / Refresh) with `renderContextMenu` slot
  • Inline rename via F2 / double-click; optional `validateRename`
  • Drag-and-drop within the manager (move) + drag-from-OS (`onExternalDrop`)
  • Cycle / self-drop pre-validation; only folders are valid drop targets
  • Lazy children loading via `onLoadChildren` + exported `mergeLoadedChildren` helper
  • Built-in sort menu (Name / Modified / Size / Type, asc/desc) + sortable list-view headers
  • Search input filtering current folder by name (case-insensitive substring)
  • Type-ahead select (typing letters jumps focus to matching name)
  • Status bar (item count / selected / total size); replaceable via `renderStatusBar`
  • Sidebar + details slots for dual-pane / preview compositions
  • List-view virtualization at >=200 items via TanStack Virtual
  • Object-shape callbacks (F-cross-12-correct from day one)
  • WCAG 2.1 AA — `role=grid`, `aria-multiselectable`, roving tabindex, live-region announcements

Tags

filefoldermanagernavigationexplorerfinderfilesystemgridlist

Dependencies

shadcn primitives: alert-dialog, button, context-menu, dropdown-menu, input, tooltip
npm peer deps: @tanstack/react-virtual@^3.13.24, lucide-react@^1.11.0