File Picker
A backend-agnostic filesystem browser and picker — drill into folders, search, create a folder in place, and pick a file or a directory. No component knows what a "file" physically is; every byte arrives through host callbacks.
Two exports, one file: FileBrowser is the shell-less browsing panel — drop
it into a Dialog, a Sheet, a Popover, or straight into the page. FilePickerDialog
is the same panel dropped into an acrylic Dialog with confirm/cancel wired
up, for the common case where a modal picker is exactly what you want. All data — directory listings,
search results, folder creation — arrives through callbacks you supply; the component has no backend
of its own and can drive a cloud drive, an object store, or an in-memory tree without any change to
this file.
Installation
npx shadcn add https://acrylic-ui.vercel.app/r/file-picker.jsonUsage
Nothing picked yet
"use client"import * as React from "react"import { Button } from "@/registry/acrylic/button"import { FilePickerDialog, type FileEntry } from "@/registry/acrylic/file-picker"const TREE: Record<string, FileEntry[]> = { "/": [ { name: "Documents", isDir: true }, { name: "Music", isDir: true }, { name: "notes.txt", isDir: false }, ], "/Documents": [ { name: "Invoices", isDir: true }, { name: "resume.pdf", isDir: false }, ], "/Documents/Invoices": [{ name: "2026-01.pdf", isDir: false }], "/Music": [{ name: "album.flac", isDir: false }],}export default function FilePickerDemo() { const [open, setOpen] = React.useState(false) const [picked, setPicked] = React.useState<string | null>(null) const [extra, setExtra] = React.useState<Record<string, FileEntry[]>>({}) const tree = { ...TREE, ...extra } return ( <div className="flex w-full max-w-md flex-col items-start gap-3"> <Button variant="neutral" size="small" onClick={() => setOpen(true)}> Choose a folder </Button> <p className="text-sm text-muted-foreground"> {picked ? `Picked: ${picked}` : "Nothing picked yet"} </p> <FilePickerDialog open={open} onOpenChange={setOpen} select="dir" loadDir={async (path) => tree[path] ?? []} onCreateFolder={async (parent, name) => { if (name.includes(":")) throw new Error("A name cannot contain a colon") setExtra((prev) => ({ ...prev, [parent]: [...(tree[parent] ?? []), { name, isDir: true }], [`${parent === "/" ? "" : parent}/${name}`]: [], })) }} onCommit={(path) => setPicked(path)} /> </div> )}Shell-less — FileBrowser, for hosting inside your own container:
import { FileBrowser } from "@/components/acrylic/file-picker"
<FileBrowser
loadDir={(path) => api.listDir(path)}
select="dir"
defaultPath="/"
onValueChange={(path) => setChosenDir(path)}
/>Official shell — FilePickerDialog, a ready-made modal:
import { FilePickerDialog } from "@/components/acrylic/file-picker"
<FilePickerDialog
open={open}
onOpenChange={setOpen}
select="dir"
loadDir={(path) => api.listDir(path)}
onCreateFolder={(parent, name) => api.mkdir(parent, name)}
onCommit={(path) => setChosenDir(path)}
/>Anatomy
FileBrowserowns everything about browsing: the current path, the loaded entries, the breadcrumb, search, the new-folder draft row, and keyboard navigation. It does not know what contains it, and it never closes anything — there is noonOpenChange, no Escape handler, no concept of "done." Confirming a pick and dismissing whatever surface hosts the panel is entirely the shell's job.FilePickerDialogis that shell:FileBrowserinside an acrylicDialog, with a footer (Cancel / Choose) and an optionalcommitOnSelectfast path.
This split exists because a general-purpose registry component has no business deciding what
container it lives in. A Dialog baked into the browsing panel is a real trap: if a "browse for a
folder" affordance sits inside a Popover, and the folder browser itself opens as a portal-rendered
Dialog, the Popover treats the click that opens the browser as an outside click and dismisses
itself the instant the user tries to browse — the editor that was supposed to receive the picked
path is gone before the pick happens. FileBrowser being container-agnostic is what lets a host
drop it into a Popover and never hit that. Reach for FilePickerDialog when a modal is genuinely
what you want; reach for FileBrowser whenever you don't.
Data contract
loadDir(path) returns the entries directly inside path — name is a bare child name
("ep05.mp4", not a path).
searchDir(path, query), when provided, returns matches from anywhere in the subtree rooted at
path — and here name must be a path relative to path (e.g. "Season 3/ep05.mp4"), not a
bare filename. This is a real constraint, not a style preference: two files with the same bare name
in different subdirectories would otherwise be indistinguishable in the results list, and there
would be no way to reconstruct the absolute path of whichever one got picked. The component displays
name exactly as given and joins it onto path on selection, so a bare filename from searchDir
silently produces a wrong absolute path for anything not at the top level.
Capabilities unlock by callback
Only loadDir is required. Each optional callback you omit turns off the feature it powers —
never a disabled stand-in for it:
| Omitted | Effect |
|---|---|
searchDir | The search box still renders, but a query filters only the entries already loaded for the current level instead of searching the subtree. |
onCreateFolder | The new-folder button does not render at all — not a disabled button, since there is nothing to explain disabling it for. |
Validation split
Creating a folder splits validation by who can actually know the answer:
- Empty name and duplicate name are rejected locally, before any call to
onCreateFolder— the component already holds the current directory's listing, so this is a free check with no round trip. - Illegal characters are the host's job.
onCreateFoldershouldrejectwith anErrorwhose message is meant for display; the component has no way to know AList's, S3's, or Windows's rules for what a filename may contain, and guessing a rule would only produce false rejections for otherwise-legal names. A rejection's message is shown verbatim under the still-open draft row — the row does not disappear, so the name can be corrected in place.
Selection semantics
select controls which kind of entry can become value:
"dir"(default) — the selection is whatever level you're currently browsing, not a row in the list. Clicking a folder both drills into it and selects it in the same action — there is no separate "choose this folder without entering it" target, by design: a folder row that both navigates and has its own independent "select" hit area is the single easiest thing to mis-click in a picker like this."file"— folders are still shown as browsing context and clicking one still drills in, but only files can becomevalue. Folders are never disabled — they stay navigable in every mode, since drilling in is how you reach the files underneath."any"— both files and folders are selectable, but clicking a folder still drills in first (same rule as"dir"). This meansselect="any"cannot select a folder without entering it either — that follows directly from folders always being navigable, not a bug to report.
commitOnSelect
FilePickerDialog-only, and only meaningful under select="file" or select="any" — the modes
where a click can land on a file. It commits and closes the dialog the moment a file is picked,
skipping the confirm button. Under the default select="dir" it is a no-op: every drill-down
already reports the browsed level as the current selection, so committing on that same event would
close the dialog before the user reached the folder they meant to land in.
Localization
Every user-facing string lives in one flat FileBrowserLabels dictionary and defaults to English —
the registry ships no other language. Pass a partial labels; it's merged over the defaults, so
you only override what you translate:
<FileBrowser
labels={{ root: "全部文件", empty: "此文件夹为空", newFolder: "新建文件夹" }}
…
/>| key | default | where it shows |
|---|---|---|
root | "All files" | root breadcrumb segment; the listbox's aria-label |
empty | "This folder is empty" | current directory has zero entries |
noMatches | "No matches" | a search (either mode) returns nothing |
selectionEmpty | "Nothing selected" | bottom selection strip when value is unset |
searchLocal | "Search this folder…" | search input placeholder/aria-label — no searchDir given |
searchSubtree | "Search everything below…" | search input placeholder/aria-label — searchDir given |
newFolder | "New folder" | new-folder button aria-label; draft row input aria-label |
newFolderPlaceholder | "Folder name" | draft row input placeholder |
nameRequired | "Name required" | local validation — empty name submitted |
nameTaken | "That name is taken" | local validation — name matches an existing entry |
title | "Choose a location" | FilePickerDialog only — dialog title |
description | "Browse the list below, then confirm your selection." | FilePickerDialog only — sr-only dialog description |
cancel | "Cancel" | FilePickerDialog only — cancel button |
confirm | "Choose" | FilePickerDialog only — confirm button |
loading also exists on the type for forward compatibility, but nothing currently renders it — the
loading state is a Skeleton placeholder, not text.
DEFAULT_FILE_BROWSER_LABELS is exported if you want to read or spread the defaults.
API Reference
FileBrowser
| prop | type | default | description |
|---|---|---|---|
loadDir | (path: string) => Promise<FileEntry[]> | — | required — entries directly inside path |
searchDir | (path: string, query: string) => Promise<FileEntry[]> | — | subtree search; see Data contract for the name format |
onCreateFolder | (parentPath: string, name: string) => Promise<void> | — | create a subfolder; omit to hide the new-folder affordance — see Validation split |
select | "dir" | "file" | "any" | "dir" | which kind of entry can become value — see Selection semantics |
value | string | null | — | controlled selected absolute path |
onValueChange | (path: string | null, entry: FileEntry | null) => void | — | fires on every selection change; entry is null when the selection is the browsed level itself rather than a clicked row (initial mount, a breadcrumb jump) |
defaultPath | string | "/" | uncontrolled starting browse path |
path | string | — | controlled browse path |
onPathChange | (path: string) => void | — | fires whenever the browse path changes |
labels | Partial<FileBrowserLabels> | English | see Localization |
className | string | — | on the panel's own wrapper <div> |
FilePickerDialog
All of the above (its className styles the inner FileBrowser, not the Dialog surface), plus:
| prop | type | default | description |
|---|---|---|---|
open | boolean | — | required |
onOpenChange | (open: boolean) => void | — | required |
commitOnSelect | boolean | false | pick-and-close for select="file"/"any" — see above |
onCommit | (path: string, entry: FileEntry | null) => void | — | fires when the user confirms (Choose button, or a commitOnSelect pick) |
FileEntry
type FileEntry = {
name: string
isDir: boolean
/** Opaque host payload handed back untouched wherever this entry surfaces — never read here. */
meta?: unknown
}Path helpers
joinPath and pathCrumbs are exported alongside the components. Both are exactly what
FileBrowser itself uses internally to build child paths and breadcrumb segments, so a host
implementing loadDir/searchDir against its own backend can reuse the same joining and
breadcrumb-building logic instead of re-deriving it:
function joinPath(base: string, name: string): stringJoins a child name onto a directory path without doubling the root slash — joinPath("/", "docs")
is "/docs", joinPath("/docs", "a.txt") is "/docs/a.txt".
function pathCrumbs(
path: string,
rootLabel: string
): Array<{ label: string; path: string }>Turns an absolute path into breadcrumb segments, root first — useful if you need to render your own
path indicator outside the component (e.g. in a title bar) that stays in sync with path.
Not supported
Multi-select, virtualization, rename/delete/move, and a built-in "switch search root" control are
all deliberately out of scope. For each, the intended answer is host-side, not a prop this component
should grow: build multi-select or rename/delete/move on top of FileEntry.meta and your own action
UI, virtualize the listing yourself if a directory can hold enough entries to need it, and swap
loadDir/searchDir (or drive path/defaultPath) if you need to point browsing at a different
root.