Folder Tree
A Notion-style library tree — sections of folders, pages and files, lazy folders, tree keys, row menus, drag into a folder, and a Move picker.
- Status
- Since
0.23.73- Accessibility pattern
- nav of nested lists, one tab stop, tree arrow keys
Last updated
Install
Add Folder Tree from the VegaStack registry. The CLI verifies the item's integrity hash before writing it.
pnpm dlx shadcn@latest add @vegastack/folder-treeThe same command installs the registry items it composes: @vegastack/button, @vegastack/file-kind, @vegastack/skeleton, @vegastack/use-announcer, @vegastack/use-drag-reorder, @vegastack/use-list-nav.
Usage
import { FolderTree, FolderTreeRowAction } from "@/components/ui/folder-tree";
<FolderTree
aria-label="Library"
sections={[
{ id: "shared", label: "Shared" },
{ id: "private", label: "Private" },
]}
rootItems={{ shared: sharedTop, private: privateTop }}
loadChildren={(id) => fetchChildren(id)}
expanded={expanded}
onExpandedChange={setExpanded}
activeId={openItemId}
renderRowActions={(node) => <RowMenu node={node} />}
onMove={({ ids, targetId, targetSection }) =>
moveItems(ids, targetId, targetSection)
}
/>;Scope
- Owns the tree's structure, its keyboard model, lazy children (loading, error and empty rows, the cache), the "Show all" cut-off, pointer drag into a folder or section, and picker mode.
- Does not own the data or its order (the host sorts), fetching, renaming, uploads onto a row,
or what a menu item does —
onMoveand the row menu are the host's. - Compose with
DropdownMenufor the row menu (its trigger is aFolderTreeRowAction),Dialogfor a Move dialog holding amode="picker"tree, andSidebaror aResizablePanelfor the pane it lives in.
Anatomy
Examples
Library
Sections hold folders, pages and files, sorted by the host. A folder's chevron opens it; the row is
a link (href, or your router's Link through linkRender) and the open item is marked. Each row
has a ⋯ menu, shown on hover, on focus and on the open row. Drag a row into the middle of a folder,
or onto a section heading, to move it there — a closed folder opens after a moment's rest. The
tree shows the new place once your data says so: onMove may be async.
Row states
A folder opened for the first time calls loadChildren and shows a loading row until it
resolves; a failure shows "Couldn't load · Retry"; a folder with nothing in it says "Empty" (mark
one known to be empty with hasChildren: false and it never loads). Past maxChildren (200 by
default) a folder ends with a "Show all" row that calls onShowAll, or lists the rest in place
without it. badge adds a trailing count.
Move dialog
mode="picker" shows folders only, marks one selected, and has no menus and no drag — the tree
for a Move dialog, which is also the keyboard path for every drag: put "Move…" in the row menu.
API Reference
| Prop | Type | Default | Description |
|---|---|---|---|
aria-label* | string | — | The tree's accessible name ("Library"). |
expanded* | string[] | — | The open folders' ids. |
onExpandedChange* | (ids: string[]) => void | — | Called with the new list when a folder opens or closes. |
rootItems* | Record<string, FolderTreeNode[]> | — | The top-level items of each section, keyed by section id. |
activeId | string | — | The item the page shows — aria-current="page" and the active tint. |
childrenOf | Record<string, FolderTreeNode[] | undefined> | — | Children the host already holds, by folder id — they win over the cache. undefined for a
folder means "not loaded yet". |
collapsedSections | string[] | — | Closed section ids. Uncontrolled (all open) unless set. |
labels | Partial<FolderTreeLabels> | — | Override any rendered or announced string. |
linkRender | useRender.RenderProp<Record<string, unknown>> | — | The element a row's link renders — a framework Link. Receives href and the row's props. |
loadChildren | ((id: string) => Promise<FolderTreeNode[]>) | — | Load a folder's children the first time it opens (and again on Retry). The result is cached
unless childrenOf holds the folder. |
maxChildren | number | 200 | The most children a folder lists before a "Show all" row. |
mode | "nav" | "picker" | "nav" | nav — links, menus and drag; picker — folders only, one selected, no menus, no drag,
for a Move dialog. |
onCollapsedSectionsChange | ((ids: string[]) => void) | — | Called when a section heading opens or closes. |
onMove | ((move: FolderTreeMove) => void | Promise<void>) | — | Move items by pointer drag. Omit it and nothing drags. May return a promise. |
onOpen | ((node: FolderTreeNode) => void) | — | Called when a row without an href is opened (click or Enter). |
onSelectedChange | ((id: string) => void) | — | Picker mode: called when a folder is chosen. |
onShowAll | ((id: string) => void) | — | Called by the "Show all" row. Without it the row lists the rest in place. |
renderRowActions | ((node: FolderTreeNode) => React.ReactNode) | — | The row's trailing actions — a FolderTreeRowAction ⋯ menu trigger. Shown on hover, on
focus and on the active row. Not rendered in picker mode. |
sections | FolderTreeSection[] | — | Section headings, in order. Without them rootItems' keys render in order, headless. |
selected | string | — | Picker mode: the chosen folder. |
Data attributes and CSS variables on FolderTree
| Attribute | Values |
|---|---|
data-active | "" |
data-expanded | "" |
data-kind | mirrors a prop or state value |
data-mode | mirrors a prop or state value |
data-slot | "folder-tree" | "folder-tree-badge" | "folder-tree-group" | "folder-tree-icon" | "folder-tree-item" | "folder-tree-label" | "folder-tree-row" | "folder-tree-row-actions" | "folder-tree-section" | "folder-tree-section-heading" | "folder-tree-toggle" |
data-state | "closed" | "open" |
--folder-tree-depth | CSS custom property |
FolderTreeNode
| Prop | Type | Default | Description |
|---|---|---|---|
id* | string | — | Stable id — the key for expanded, activeId, selected, childrenOf and moves. |
kind* | "file" | "folder" | "page" | — | A folder opens to children; a page and a file are leaves. |
label* | string | — | The row's name. |
badge | React.ReactNode | — | A trailing count or badge (an unread count, "3"). |
contentType | string | — | A file's MIME type, for its icon. |
hasChildren | boolean | — | false marks a folder known to be empty: it shows "Empty" without calling loadChildren. |
href | string | — | Where the row links. Without it the row is a button that calls onOpen. |
icon | React.ReactNode | — | Replaces the default icon — a page's emoji, say. Folders default to an open or closed
folder, pages to a document, files to their FileTypeIcon. |
FolderTreeSection
| Prop | Type | Default | Description |
|---|---|---|---|
id* | string | — | The key into rootItems. |
label* | string | — | The heading. |
action | React.ReactNode | — | A trailing action on the heading — typically a FolderTreeRowAction "+" menu. |
FolderTreeMove
| Prop | Type | Default | Description |
|---|---|---|---|
ids* | string[] | — | The moved item ids. |
targetId* | string | null | — | The folder they move into, or null for the top of targetSection. |
targetSection* | string | — | The section of the target. |
FolderTreeRowAction
A Button (ghost, icon-xs) that is a tab stop only on the active row. It takes Button's props
and needs an aria-label.
Accessibility
- A navigation pattern, not an ARIA tree: a
<nav>named byaria-label, nested lists, each row a link witharia-current="page"on the open item (a button in picker mode,aria-pressedon the selected folder) and a disclosure button named"<name> folder"witharia-expanded. The system keepsrole="tree"out on purpose: controls inside tree items trap a screen reader. - The tree is one tab stop; the active row's ⋯ button is the next. Focus shows as a background tint, never a ring.
- A drop is announced ("Moved Brand to Shared"), and so is a move the host refused.
- A loading row carries screen-reader text; a truncated name shows in full on hover.
| Key | Action |
|---|---|
| ↑ / ↓ | Previous / next visible row. |
| → | Open a closed folder or section; on an open one, its first row. |
| ← | Close an open folder or section; otherwise go to its parent. |
| Home / End | First / last visible row. |
| Enter | Follow the link, or choose the folder in picker mode. |
| * | Open every folder beside the current row. |
| A letter | Next row whose name starts with it. |
| Tab | The active row's ⋯ button, then out of the tree. |
| Contract | States tested |
|---|---|
| Behaviour | default, expanded, collapsed, active, loading, error, empty, show-all, drag-over, drag-invalid, dragging, selected, section-collapsed |
| Accessibility | semantic-html, browser-accessibility-test, keyboard-navigation, labeled, status-announcement, focus-visible |
| Visual | default, hover, focus, active, loading, error, empty, drag-over, drag-invalid, selected |
Do / Don't
Sidebar
A composable, collapsible navigation rail — provider, panel, groups, menu rows with actions, badges and submenus, plus a rail and a trigger.
Page Header
The standardized header at the top of a page — back button, breadcrumb trail, title, description, actions, secondary menu, and a favorite star.