Browse a folder tree with the path in the top bar and the selected file previewed beside it.
This field is required
announcement-key-art.psd
File info
Department info
Object metadata
npx shadcn@latest add https://sdk-components.thesqd.com/r/file-browser.jsonimport { FileBrowser } from "@/components/blocks/file-browser";import { FileBrowser } from "@/components/blocks/file-browser";
const items = [
{
name: "0. Client Provided Assets",
children: [
{
name: "campus-exterior.jpg",
src: "https://cdn.example.com/campus-exterior.jpg",
// Everything the preview lists, as one object. You name the groups
// and the rows — nothing is derived.
details: {
"File info": {
Size: "4 MB",
Modified: "Sept 27, 2026",
Kind: "JPEG image",
Access: "Church Media Squad",
},
"Upload info": {
"Content-Type": "image/jpeg",
"x-amz-meta-form_id": "wpnA50mJ",
"x-amz-meta-submission_id": "771fdcf8-63b1-4366-b746-9b391d7d3717",
},
},
},
],
},
{
name: "1. Design",
children: [
{
name: "announcement-key-art.psd",
src: "https://cdn.example.com/key-art-preview.jpg",
details: {
"File info": { Size: "176 MB", Modified: "Sept 28, 2026" },
"Department info": {
Department: "Design",
Designer: "Avery Diaz",
Stage: "In review",
Round: 3,
},
// A Supabase files.file_metadata row drops straight in.
"Object metadata": {
"Content-Type": "image/vnd.adobe.photoshop",
"x-amz-meta-source": "dropbox",
"x-amz-meta-dropbox_path": "/Church Media Company Team Folder/…/announcement-key-art.psd",
"x-amz-meta-synced_at": "2026-09-28T14:16:29.212Z",
},
},
},
],
},
// A folder with nothing in it yet — an empty children array still reads as
// a folder, so it draws a caret.
{ name: "3. Delivery", children: [] },
{
name: "run-of-show.pdf",
details: { "File info": { Size: "863 KB", Modified: "Sept 30, 2026" } },
},
];
export function Example() {
return (
<FileBrowser
items={items}
breadcrumbs={[{ label: "Project Staging" }, { label: "Video" }]}
title="1. Source Files"
defaultExpandedIds={["1. Design"]}
onSelect={(node, path) => console.log(path.join("/"), node)}
/>
);
}FileBrowser (records-shell — same container as data grid v3)
├── top bar (.records-toolbar)
│ ├── TextField search → FileTree's `filter`, or `onSearch`
│ └── actions + New (newMenu) · Filters · ⋯ (folderMenu)
├── body
│ ├── FileTree items, columns, contextMenu, loadChildren
│ │ columns → a header row + a cell per row
│ │ double-click a folder → the tree re-roots there
│ ├── divider drag to resize (or ←/→ when focused)
│ └── preview pane drawn only once a FILE is selected; closes
│ on its ✕ without clearing the selection
│ ├── top bar previewActions, left-aligned, beside the ✕
│ ├── name + art board (60%) the name over the artwork, flush left;
│ │ an image keeps its OWN shape — no letterbox
│ └── details cards (40%) ONE card per group, rows as a stacked
│ DescriptionList, a MIME type as a badge;
│ stack under the board below lg
└── path bar Finder's: full width, at the foot, click any
crumb to climb back out. Always opens with a
duotone house — the ROOT, and the one crumb
the fitter never folds awayThis field is required
announcement-key-art.psd
File info
Department info
Object metadata
This field is required
This field is required
This field is required
| Prop | Type | Default | Description |
|---|---|---|---|
items | FileBrowserNode[] | — | The tree. |
title | ReactNode | — | Current folder — the last static crumb. |
breadcrumbs | { label: string; href?: string }[] | [] | Ancestors above `title`, outermost first. The selected file's own folders are appended automatically. |
maxCrumbs | number | — | Hard cap on visible crumbs. Hidden below `sm`, where the toolbar stacks and a path would cost a whole row of a phone screen. Optional — by default the trail shows as many as fit on its row, folding the rest into one ellipsis and remeasuring when the toolbar changes width. Two is the floor (where you are, and the click that goes up), and the last crumb truncates when a single name is enormous. At the root it renders nothing: one unclickable crumb is a label, not navigation. |
actions | ReactNode | — | Replaces the leading "+ New" button. The Filters control and the ⋯ menu are their own props, so an app with its own add-flow keeps both. |
newMenu | DropdownMenuItemSpec[] | — | What the "+ New" button offers. Defaults to Upload files and New folder — the two things that put something in a folder, which is one button's worth of intent rather than two. An empty array hides it. |
newLabel | ReactNode | "New" | |
folderMenu | DropdownMenuItemSpec[] | — | Entries in the ⋯ menu — the SDK's dropdown item specs. Defaults to Share folder, Copy link, Download folder, Rename, Delete. An empty array hides it. |
folderActionsLabel | string | "Folder actions" | Accessible name for the ⋯ trigger. |
rootId / defaultRootId | string | null | — | The folder the tree is rooted at — Finder's "you are inside this". Pass `rootId` to drive it from outside (a URL segment, a restored session); leave it undefined and a double-click sets it. |
onRootChange | (id: string | null, path: string[]) => void | — | Fires when a double-click or a path-bar crumb re-roots the tree, with the node's id and its ancestor names — everything a URL needs. `null` means back to the top. |
rootPath | string[] | — | Crumb labels for a controlled `rootId` whose node hasn't loaded yet. Without it the block splits the id on `/`, which is right for an object key. |
pdfHeight | number | string | 420 | Height of the PDF viewer in the preview pane. A file ending `.pdf` with a `src` is read in place with the SDK's PdfViewer — toolbar, search, thumbnails — rather than shown as an Acrobat glyph on a grey board. The viewer is a `next/dynamic` chunk with `ssr: false`, so react-pdf only loads when a PDF is actually opened. |
closeLabel | string | "Close preview" | Accessible name for the preview's close button. |
value / defaultValue | string | null | — | Selected node id — controlled and uncontrolled. |
onValueChange | (id: string) => void | — | Fires with the newly selected node id. |
onSelect | (node: FileBrowserNode, path: string[]) => void | — | Fires with the node and its ancestor names, root first. |
defaultExpandedIds | string[] | — | Folders open on first render. |
rowClassName / rowIndexCap | string / number | — | Forwarded to FileTree. Every row carries its place in the visible order as `--stagger-index` (capped at `rowIndexCap`, 12), which is what lets a listing reveal one row at a time — transitions.dev's texts reveal. The block does this for you on every listing that lands. |
columns | FileTreeColumn[] | — | Columns drawn to the right of every row, under a header naming them: `{ id, header, width?, align?, cell(node, path) }`. The header matches data grid v3's — 35px tall, 12.5px medium, muted — so a page of both reads as one thing. Adding columns turns off the scroller's `scroll-fade`, since the mask would dissolve the sticky header rather than the rows. |
nameHeader | ReactNode | "Name" | Header above the first column. |
contextMenu | FileTreeMenuItem[] | ((node, path) => FileTreeMenuItem[]) | — | Right-click menu — forwarded to FileTree unchanged. |
loadChildren | (node, path) => Promise<FileTreeNode[]> | — | Lazy folder loading — forwarded to FileTree. One `ListObjectsV2` per prefix. |
showFilters | boolean | true | The Filters button in the toolbar — the SDK's shared FilterBuilder, evaluated over the tree. A folder survives a filter its contents pass, so a match three levels down keeps the path to it, and a folder whose children haven't loaded survives either way: on a lazy tree nothing here knows what is inside it yet, and pruning it would empty the screen for a filter the bucket does match. It is judged when it opens. A filter that has to reach unloaded data belongs on the server — hand the query to `onSearch` instead. |
filterFields | FilterField[] | — | What the builder can filter on. Defaults to Name, Kind (file or folder) and Extension, whose vocabulary is read off the tree you passed. A node's `details` are flattened into the record the filter sees, so a group like `Department` is filterable just by existing. |
filterValue | FilterGroup | — | Controlled filter tree. Pair with `onFilterChange`. |
onFilterChange | (value: FilterGroup) => void | — | Fires with the built tree whenever it changes. |
searchable | boolean | true | Search box above the tree, wired to FileTree's `filter`. |
searchPlaceholder | string | "Search files" | |
onSearch | (query: string) => void | — | Hands the query to a server instead of filtering what's loaded. Debounced, and called with `""` when the box is cleared or drops under `searchMinLength` — the cue to drop the results and show the tree again. Passing it switches local filtering off entirely. |
searchResults | FileBrowserNode[] | null | null | What the server found, rendered in place of the tree. `null` means not searching; `[]` means the search ran and matched nothing. |
searching | boolean | false | A request is in flight. The pane shows NOTHING while it is — not a spinner, not the previous query's hits, and not the empty state, which would claim this query matched nothing. The results then arrive a row at a time. |
searchDebounce | number | 250 | Idle time before `onSearch` fires, in ms. |
searchMinLength | number | 2 | Characters before `onSearch` fires at all. |
searchEmptyMessage | ReactNode | "Nothing in the bucket matched that." | |
previewActions | ReactNode | — | Buttons in the preview pane's top bar, left-aligned on the same line as the ✕ — the first place a reader looks for "open this", and above the fold however tall the artwork is. The slot draws them compact, so pass ordinary `size="sm"` buttons. Defaults to Download / Share. |
treeEmpty | ReactNode | — | The tree's empty state — an empty folder, or a search that matched nothing. Defaults to the SDK `Empty`, the same component the inbox and the preview pane draw, so "nothing here" looks the same wherever it happens. Pass a node to use an app's own. |
treeEmptyTitle | string | "Nothing here" | |
treeEmptyMessage | string | "This folder is empty." | |
searchEmptyTitle | string | "No matches" | |
previewEmptyTitle | string | "Nothing selected" | Title of the preview pane's empty state. |
previewEmptyMessage | string | "Pick a file to preview it here." | Description under it — the sentence that says what to do. |
previewEmpty | ReactNode | — | The whole empty state, when the stock `Empty` isn't what the surrounding app uses. Wins over the title/message pair. |
detailsLabel | ReactNode | "Details" | Group heading used when `details` is flat rather than grouped. |
detailsPreviewCount | number | 6 | Rows per group before the "Show N more" toggle. |
formatDetailKey | (key: string) => string | — | Key display. Defaults to stripping the `x-amz-meta-` / `x-goog-meta-` prefix. |
backLabel | ReactNode | "Files" | Label on the button that returns to the tree below `sm`, where the two panes don't fit side by side and picking a file swaps to its preview. |
treeWidth | number | 346 | Tree column width, in px — the starting width when `resizable`, and what a double-click on the divider returns to. |
resizable | boolean | true | Drag the divider to resize the two panes — the sidebar rail's behavior: a few px of slop before it counts as a drag, a forced `col-resize` cursor while it lasts. The divider is a focusable `separator`, so ←/→ nudge it (with Shift for a bigger step), Home resets, and a double-click does the same. |
minTreeWidth | number | 200 | Lower clamp for the dragged width. |
maxTreeWidth | number | 560 | Upper clamp. Capped further at runtime so the preview always keeps 220px. |
folderMessage | ReactNode | "Open the folder to see what's inside." | Shown for a folder that carries no `details` of its own. A folder never draws the image board — it gets a tile the size of its heading instead. |
height | number | string | "30rem" | Shell height. Both panes scroll inside it. |
className | string | — | Extends the shell. |
| Prop | Type | Default | Description |
|---|---|---|---|
…FileTreeNode | FileTreeNode | — | Every FileTree field — `name`, `children`, `id`, `type`, `icon`, `badge`, `hasChildren`, `disabled`, `data`. |
src | string | — | The URL the preview loads, and what it loads decides how: an image fills the art board, a `.pdf` is read in place with the SDK's PdfViewer, a playable video gets the VideoPlayer, and audio gets the AudioPlayer's waveform — the only view of a sound file that says anything about it — with a transport button of the pane's own and a speed control. `playOnSeek={false}`, so dragging the playhead finds a moment without committing to hearing it. Absent → the file's own Adobe mark or Phosphor glyph. |
details | Record<string, unknown> | null | — | Everything the preview lists under the file, as one JSON object — you choose the labels, the values, and the grouping. `{ "File info": { Size, Modified }, "Department info": { … } }` renders a heading per group, in order. A flat object (a Supabase `files.file_metadata` row, say) renders as one group titled by `detailsLabel`. Nested values render as JSON; each group collapses past `detailsPreviewCount`. Falls back to FileTree's `data` passthrough when absent, so a tree from `fileTreeFromKeys` lists its source object with no mapping. |