Skip to content
Component installs need the registry setup
VegaStack Design

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
stable
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-tree

The 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 — onMove and the row menu are the host's.
  • Compose with DropdownMenu for the row menu (its trigger is a FolderTreeRowAction), Dialog for a Move dialog holding a mode="picker" tree, and Sidebar or a ResizablePanel for the pane it lives in.

Anatomy

FolderTree — 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"
FolderTreeRowAction — data-slot="folder-tree-row-action"

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

PropTypeDefaultDescription
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.
activeIdstring—The item the page shows — aria-current="page" and the active tint.
childrenOfRecord<string, FolderTreeNode[] | undefined>—Children the host already holds, by folder id — they win over the cache. undefined for a folder means "not loaded yet".
collapsedSectionsstring[]—Closed section ids. Uncontrolled (all open) unless set.
labelsPartial<FolderTreeLabels>—Override any rendered or announced string.
linkRenderuseRender.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.
maxChildrennumber200The 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.
sectionsFolderTreeSection[]—Section headings, in order. Without them rootItems' keys render in order, headless.
selectedstring—Picker mode: the chosen folder.

Data attributes and CSS variables on FolderTree

AttributeValues
data-active""
data-expanded""
data-kindmirrors a prop or state value
data-modemirrors 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-depthCSS custom property

FolderTreeNode

PropTypeDefaultDescription
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.
badgeReact.ReactNode—A trailing count or badge (an unread count, "3").
contentTypestring—A file's MIME type, for its icon.
hasChildrenboolean—false marks a folder known to be empty: it shows "Empty" without calling loadChildren.
hrefstring—Where the row links. Without it the row is a button that calls onOpen.
iconReact.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

PropTypeDefaultDescription
id*string—The key into rootItems.
label*string—The heading.
actionReact.ReactNode—A trailing action on the heading — typically a FolderTreeRowAction "+" menu.

FolderTreeMove

PropTypeDefaultDescription
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 by aria-label, nested lists, each row a link with aria-current="page" on the open item (a button in picker mode, aria-pressed on the selected folder) and a disclosure button named "<name> folder" with aria-expanded. The system keeps role="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.
KeyAction
↑ / ↓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 / EndFirst / last visible row.
EnterFollow the link, or choose the folder in picker mode.
*Open every folder beside the current row.
A letterNext row whose name starts with it.
TabThe active row's ⋯ button, then out of the tree.
ContractStates tested
Behaviourdefault, expanded, collapsed, active, loading, error, empty, show-all, drag-over, drag-invalid, dragging, selected, section-collapsed
Accessibilitysemantic-html, browser-accessibility-test, keyboard-navigation, labeled, status-announcement, focus-visible
Visualdefault, hover, focus, active, loading, error, empty, drag-over, drag-invalid, selected

Do / Don't

Do
Keep the data sorted and in your state, persist `expanded`, and put Move… in the row menu with a picker-mode tree in a dialog.
Don't
Nest editing fields or checkboxes in rows, or rely on drag alone to move items — the keyboard needs the Move… path.
Do
Pass `hasChildren: false` for a folder you know is empty, and `childrenOf` for children you already hold.
Don't
Reorder rows by drag — the tree has no manual order; a drop moves an item into a folder.

On this page