Tree expand
Free · MITFile or category tree with spring expand/collapse, indentation guides, a selection highlight that glides between rows, and full arrow-key, Enter and type-ahead navigation.
Installation
$ npx shadcn@latest add https://goodui.dev/r/tree-expand.jsonThe file lands in components/goodui/tree-expand.tsx and is yours to edit. With the @goodui registry added, npx shadcn@latest add @goodui/tree-expand works too.
Usage
import * as React from "react"
import { FileText, Folder, FolderOpen } from "lucide-react"
import { TreeExpand, type TreeExpandNode } from "@/components/goodui/tree-expand"
const folder = { icon: <Folder />, openIcon: <FolderOpen /> }
const DOCUMENTS: TreeExpandNode[] = [
{
id: "invoices",
label: "Invoices",
...folder,
children: [
{ id: "inv-1042", label: "INV-1042 Ava Thompson.pdf", icon: <FileText />, meta: "84 KB" },
{ id: "inv-1043", label: "INV-1043 Noah Kim.pdf", icon: <FileText />, meta: "79 KB" },
],
},
{
id: "contracts",
label: "Contracts",
...folder,
children: [{ id: "lease", label: "Studio lease 2026.pdf", icon: <FileText />, meta: "1.1 MB" }],
},
{ id: "pricelist", label: "Price list.pdf", icon: <FileText />, meta: "156 KB" },
]
export function DocumentTree() {
const [file, setFile] = React.useState<TreeExpandNode | null>(null)
return (
<div className="space-y-2">
<TreeExpand
label="Documents"
nodes={DOCUMENTS}
defaultExpanded={["invoices"]}
onSelectedChange={(_, node) => setFile(node && !node.children ? node : null)}
/>
<p className="text-xs text-muted-foreground">{file ? `Selected: ${file.label}` : "Nothing selected"}</p>
</div>
)
}When to use
- File and document browsers: invoices by year, client folders, exports.
- Picking from nested categories, e.g. services grouped by category on a booking form.
- Navigation for deep settings or docs where the hierarchy itself helps people find things.
- One level of sections with rich content inside: use accordion-height.
- Searching a large flat list of commands or pages: use command-palette-morph.
- Records with a summary row and actions: use expandable-row-list.
| If you need | Use |
|---|---|
| File browser where clicking a folder opens it | toggleOnSelect (default true) |
| Folders are selectable on their own (e.g. pick a target folder) | toggleOnSelect={false}: the chevron toggles, the row selects |
| Deep trees where levels are easy to lose | guides (default true), indent={18} |
| Shallow category picker | guides={false} indent={24} |
Examples
Controlled expand / collapse all
const [expanded, setExpanded] = React.useState<string[]>([])
const allFolders = ["invoices", "contracts"]
<button onClick={() => setExpanded(expanded.length ? [] : allFolders)}>
{expanded.length ? "Collapse all" : "Expand all"}
</button>
<TreeExpand label="Documents" nodes={DOCUMENTS} expanded={expanded} onExpandedChange={setExpanded} />Category picker where only leaves are selectable
const [service, setService] = React.useState<string | null>(null)
<TreeExpand
label="Services"
nodes={SERVICES}
guides={false}
indent={24}
selected={service}
onSelectedChange={(id, node) => {
if (id && node && !node.children) setService(id)
}}
/>API reference
| Prop | Type | Default |
|---|---|---|
| nodes* Top-level nodes. | TreeExpandNode[] | — |
| expanded Expanded node ids (controlled). | string[] | — |
| defaultExpanded Node ids expanded on first render (uncontrolled). | string[] | [] |
| onExpandedChange Called with the new list of expanded ids when a folder opens or closes. | (expanded: string[]) => void | — |
| selected Selected node id (controlled). | string | null | — |
| defaultSelected Node id selected on first render (uncontrolled). | string | null | null |
| onSelectedChange Called with the id and node when a row is clicked or chosen with Enter or Space. | (id: string | null, node: TreeExpandNode | null) => void | — |
| guides Draw vertical indentation guides. | boolean | true |
| indent Pixels per nesting level. | number | 18 |
| toggleOnSelect Clicking a parent row (or pressing Enter on it) also expands or collapses it. | boolean | true |
| label Accessible name of the tree. | string | "Tree" |
Also accepts every <ul> attribute.
Accessibility
| ArrowDown / ArrowUp | Moves focus to the next or previous visible row (no wrap). |
| ArrowRight | On a closed folder, expands it; on an open folder, moves to its first child. Mirrored (ArrowLeft) under dir="rtl". |
| ArrowLeft | On an open folder, collapses it; otherwise moves to the parent folder. Mirrored (ArrowRight) under dir="rtl". |
| Home / End | Moves focus to the first or last visible row. |
| Enter / Space | Selects the focused row; on a folder with toggleOnSelect, also opens or closes it. |
| a–z, 0–9 | Type-ahead: jumps to the next visible row whose label starts with the typed letters (letters typed within 600 ms combine). |
| Tab | The tree is one tab stop; focus enters on the focused, selected or first row. |
- Uses the ARIA tree pattern: role="tree" named by label, role="treeitem" rows with aria-level, aria-expanded on folders, aria-selected, and role="group" for children.
- Roving tabindex: only one row is in the tab order, the rest are reached with the arrow keys.
- Disabled nodes get aria-disabled and cannot be selected; clicks, the chevron, Enter and the arrow keys never expand or collapse them, but they stay reachable with the arrow keys.
- The focus ring is drawn on the row only, not around the whole open subtree.
- Icons, chevrons and the selection highlight are aria-hidden.
Motion
- Children open by animating height to auto on a spring (bounce 0, 0.34 s) with a 0.2 s fade in and a 0.14 s fade out.
- The chevron turns 90° and the selection highlight glides from row to row (shared layoutId), both on a spring (stiffness 500, damping 40).
- Reduced motion: all of these use duration 0, so folders open and the selection moves instantly.
Notes for AI
Coding agents read these rules through MCP and llms.txt, or paste this page with Copy page.
- Node ids must be unique across the whole tree, not just among siblings.
- A node is a folder only if it has a non-empty children array; folder icons are just icon/openIcon.
- onSelectedChange fires for folders too; check node.children to accept only leaves.
- When passing selected (controlled), update it from onSelectedChange or the selection never moves.
- label on a node must be plain text: it is used for type-ahead.
FAQ
How do I expand or collapse everything?
Pass expanded and onExpandedChange, then set expanded to every folder id, or to [].
Can a folder be selected without opening it?
Yes, set toggleOnSelect={false}. Clicking the row selects it; clicking the chevron or pressing ArrowRight/ArrowLeft opens and closes it.
Related
Accordion whose panels spring open to their real height (motion height:auto) with a rotating chevron, in single or multiple mode, with arrow-key, Home and End navigation between headers.
Appointment rows that open inline into a raised card with notes and working actions, while neighbouring rows smoothly make room.
A search field morphs into a command palette through a shared layout animation, with fuzzy matching, highlighted letters, grouped results, a sliding selection, arrow keys, Enter, Esc and Ctrl/Cmd+K.