File browser

Browse a folder tree with the path in the top bar and the selected file previewed beside it.

1. Design
announcement-key-art.psd
title-slide.png
layout.indd
run-of-show.pdf

announcement-key-art.psd

announcement-key-art.psd

File info

Size
176 MB
Modified
Sept 28, 2026
Kind
Photoshop document
Access
Church Media Squad

Department info

Department
Design
Designer
Avery Diaz
Stage
In review
Round
3
Due
Oct 1, 2026

Object metadata

Content-Type
image/vnd.adobe.photoshop
source
dropbox
dropbox_id
id:UqQPn773niAAAAAAAC30zw
dropbox_rev
5e2829c971d5b9d082ac1
dropbox_path
/Church Media Company Team Folder/2. Client Accounts/2306 - Antioch Fort Worth/Announcements Sept 27/1. Source Files/announcement-key-art.psd
content_hash
01b55b970b79e17929e803896240c9d38b61901869a4eb8a6ede361d51b54603
Installation
$Terminal
npx shadcn@latest add https://sdk-components.thesqd.com/r/file-browser.json
Usage
Pass a nested `items` array. Nodes are FileTree nodes with a few preview fields — `src`, `size`, `modified`, `sharedWith`.
TSImport
import { FileBrowser } from "@/components/blocks/file-browser";
TSExample
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)}
    />
  );
}
Composition
Anatomy of the block.
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 away
Default
Search and the folder actions up top, the tree below, and Finder's path bar at the foot. Double-click a folder and the tree re-roots there — the crumbs climb back out. The preview only appears once a file is picked; the key art file carries three `details` groups. Right-click a row for the menu.
1. Design
announcement-key-art.psd
title-slide.png
layout.indd
run-of-show.pdf

announcement-key-art.psd

announcement-key-art.psd

File info

Size
176 MB
Modified
Sept 28, 2026
Kind
Photoshop document
Access
Church Media Squad

Department info

Department
Design
Designer
Avery Diaz
Stage
In review
Round
3
Due
Oct 1, 2026

Object metadata

Content-Type
image/vnd.adobe.photoshop
source
dropbox
dropbox_id
id:UqQPn773niAAAAAAAC30zw
dropbox_rev
5e2829c971d5b9d082ac1
dropbox_path
/Church Media Company Team Folder/2. Client Accounts/2306 - Antioch Fort Worth/Announcements Sept 27/1. Source Files/announcement-key-art.psd
content_hash
01b55b970b79e17929e803896240c9d38b61901869a4eb8a6ede361d51b54603
Columns
`columns` turns the tree into a table: a header row naming what the numbers are — 35px, 12.5px, the same type as data grid v3's — and a cell per row. A cell is a function of the node, so it can read what the nodes already carry (Size and Modified come off the same `details` the preview draws) or compute something no row holds at all — Files counts the descendants of a folder, and stays blank on a file, which is one.
NameFilesSizeModified
0. Client Provided Assets3
campus-exterior.jpg4 MBSept 27, 2026
worship-night.jpg6.6 MBSept 26, 2026
brand-guidelines.pdf3.3 MBAug 2, 2026
1. Design3
announcement-key-art.psd176 MBSept 28, 2026
title-slide.png2.3 MBSept 28, 2026
layout.indd18 MBSept 21, 2026
run-of-show.pdf863 KBSept 30, 2026
Search the server, not the screen
Without `onSearch` the box filters the nodes already loaded — fine for a folder, useless for a bucket where the match is three prefixes down and has never been fetched. With it, the block debounces the query, hands it over, and renders `searchResults` in place of the tree.
run-of-show.pdf
Over a bucket
The same browser over a `ListObjectsV2` page — `fileTreeFromKeys` derives the folders, and each node's id stays the full object key. Pass `loadChildren` instead for a bucket too big to walk.
design
announcement-key-art.psd176 MB
title-slide.png2.3 MB
run-of-show.pdf863 KB
API Reference
Props on FileBrowser and on each node.

FileBrowser

PropTypeDefaultDescription
itemsFileBrowserNode[]—The tree.
titleReactNode—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.
maxCrumbsnumber—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.
actionsReactNode—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.
newMenuDropdownMenuItemSpec[]—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.
newLabelReactNode"New"
folderMenuDropdownMenuItemSpec[]—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.
folderActionsLabelstring"Folder actions"Accessible name for the ⋯ trigger.
rootId / defaultRootIdstring | 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.
rootPathstring[]—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.
pdfHeightnumber | string420Height 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.
closeLabelstring"Close preview"Accessible name for the preview's close button.
value / defaultValuestring | 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.
defaultExpandedIdsstring[]—Folders open on first render.
rowClassName / rowIndexCapstring / 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.
columnsFileTreeColumn[]—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.
nameHeaderReactNode"Name"Header above the first column.
contextMenuFileTreeMenuItem[] | ((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.
showFiltersbooleantrueThe 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.
filterFieldsFilterField[]—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.
filterValueFilterGroup—Controlled filter tree. Pair with `onFilterChange`.
onFilterChange(value: FilterGroup) => void—Fires with the built tree whenever it changes.
searchablebooleantrueSearch box above the tree, wired to FileTree's `filter`.
searchPlaceholderstring"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.
searchResultsFileBrowserNode[] | nullnullWhat the server found, rendered in place of the tree. `null` means not searching; `[]` means the search ran and matched nothing.
searchingbooleanfalseA 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.
searchDebouncenumber250Idle time before `onSearch` fires, in ms.
searchMinLengthnumber2Characters before `onSearch` fires at all.
searchEmptyMessageReactNode"Nothing in the bucket matched that."
previewActionsReactNode—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.
treeEmptyReactNode—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.
treeEmptyTitlestring"Nothing here"
treeEmptyMessagestring"This folder is empty."
searchEmptyTitlestring"No matches"
previewEmptyTitlestring"Nothing selected"Title of the preview pane's empty state.
previewEmptyMessagestring"Pick a file to preview it here."Description under it — the sentence that says what to do.
previewEmptyReactNode—The whole empty state, when the stock `Empty` isn't what the surrounding app uses. Wins over the title/message pair.
detailsLabelReactNode"Details"Group heading used when `details` is flat rather than grouped.
detailsPreviewCountnumber6Rows 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.
backLabelReactNode"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.
treeWidthnumber346Tree column width, in px — the starting width when `resizable`, and what a double-click on the divider returns to.
resizablebooleantrueDrag 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.
minTreeWidthnumber200Lower clamp for the dragged width.
maxTreeWidthnumber560Upper clamp. Capped further at runtime so the preview always keeps 220px.
folderMessageReactNode"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.
heightnumber | string"30rem"Shell height. Both panes scroll inside it.
classNamestring—Extends the shell.

FileBrowserNode

PropTypeDefaultDescription
…FileTreeNodeFileTreeNode—Every FileTree field — `name`, `children`, `id`, `type`, `icon`, `badge`, `hasChildren`, `disabled`, `data`.
srcstring—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.
detailsRecord<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.