Components / Collections
Tree An expandable hierarchy such as a file explorer, docs navigation or nested permissions. Items take a title, an icon that can follow the expanded state and a trailing suffix, with single or multiple selection and full keyboard support.
import {
FileCodeIcon,
FileJsonIcon,
FileTextIcon,
FolderIcon,
FolderOpenIcon,
} from "lucide-react" ;
import { Tree, TreeItem } from "@/components/ui/tree" ;
const folder = ({ isExpanded } : { isExpanded : boolean }) =>
isExpanded ? < FolderOpenIcon /> : < FolderIcon />;
export default function TreeDemo () {
return (
< Tree
aria-label = "Project files"
variant = "bordered"
defaultExpandedKeys = {[ "app" , "components" ]}
className = "w-72"
>
< TreeItem id = "app" title = "app" icon = {folder}>
< TreeItem id = "layout" title = "layout.tsx" icon = {< FileCodeIcon />} />
< TreeItem id = "page" title = "page.tsx" icon = {< FileCodeIcon />} />
< TreeItem id = "settings" title = "settings" icon = {folder}>
< TreeItem
id = "settings-page"
title = "page.tsx"
textValue = "settings/page.tsx"
icon = {< FileCodeIcon />}
/>
</ TreeItem >
</ TreeItem >
< TreeItem id = "components" title = "components" icon = {folder}>
< TreeItem id = "ui" title = "ui" icon = {folder}>
< TreeItem id = "button" title = "button.tsx" icon = {< FileCodeIcon />} />
< TreeItem id = "tree" title = "tree.tsx" icon = {< FileCodeIcon />} />
</ TreeItem >
< TreeItem id = "header" title = "site-header.tsx" icon = {< FileCodeIcon />} />
</ TreeItem >
< TreeItem id = "package" title = "package.json" icon = {< FileJsonIcon />} />
< TreeItem id = "readme" title = "README.md" icon = {< FileTextIcon />} />
</ Tree >
);
}
CLI Manual
$ pnpm dlx shadcn@latest add @desyne/tree
The CLI installs dependencies and any other components this one uses.
import { Tree, TreeItem } from "@/components/ui/tree" ;
< Tree aria-label = "Files" defaultExpandedKeys = {[ "src" ]}>
< TreeItem id = "src" title = "src" icon = {< FolderIcon />}>
< TreeItem id = "index" title = "index.ts" icon = {< FileIcon />} />
</ TreeItem >
< TreeItem id = "readme" title = "README.md" icon = {< FileIcon />} />
</ Tree >
Nest TreeItems inside a TreeItem to give it children; the expand chevron appears automatically. Give every item a unique id.
Tree — data with real nesting that users open and close: folders, org charts, category trees, nested settings or permissions.
Disclosure — a few collapsible sections of content, not a navigable list of items.
Sidebar — app navigation with one or two levels. Use a tree inside it when the hierarchy is deep or user-defined (projects and folders).
Grid List or List Box — a flat list.
< Tree > { /* role="treegrid" */ }
< TreeItem > { /* role="row" aria-level aria-expanded */ }
{ /* checkbox (toggle selection) · indent · chevron · icon · title · suffix */ }
< TreeItem /> { /* nested items */ }
</ TreeItem >
</ Tree >
Part Renders Notes Tree<div role="treegrid">React Aria Tree. Owns expansion and selection state. variant="bordered" frames it as a card. TreeItem<div role="row">One node. Sets aria-level, aria-expanded and aria-selected. Selection checkbox CheckboxShown with selectionMode and the default selectionBehavior="toggle". Indent <span>Width follows React Aria's --tree-item-level variable (16px per level). Chevron <button slot="chevron">Only on items with children. Rotates when expanded. Not a tab stop; the row handles the keyboard. Icon <span>icon: a node, or a function of the item state (for open/closed folders).Suffix <span>suffix: counts, sizes, badges or a status dot, pushed to the end.
Pass data to items and a render function as children. For nested data, render a Collection of the node's children inside each TreeItem, calling the same function recursively. React Aria only renders expanded branches.
import { FileIcon, FolderIcon, FolderOpenIcon } from "lucide-react" ;
import { Collection } from "react-aria-components" ;
import { Tree, TreeItem } from "@/components/ui/tree" ;
interface Node {
id : string ;
name : string ;
size ?: string ;
children ?: Node [];
}
const files : Node [] = [
{
id: "brand" ,
name: "Brand" ,
children: [
{ id: "logo-svg" , name: "logo.svg" , size: "4 KB" },
{ id: "logo-png" , name: "logo@2x.png" , size: "88 KB" },
{
id: "fonts" ,
name: "Fonts" ,
children: [
{ id: "inter" , name: "Inter.woff2" , size: "312 KB" },
{ id: "mono" , name: "JetBrainsMono.woff2" , size: "96 KB" },
],
},
],
},
{
id: "contracts" ,
name: "Contracts" ,
children: [
{ id: "msa" , name: "MSA – Northwind.pdf" , size: "1.2 MB" },
{ id: "nda" , name: "NDA – Globex.pdf" , size: "240 KB" },
],
},
{ id: "roadmap" , name: "Roadmap 2027.key" , size: "18 MB" },
];
export default function TreeDynamic () {
return (
< Tree
aria-label = "Shared drive"
items = {files}
defaultExpandedKeys = {[ "brand" ]}
variant = "bordered"
className = "w-80"
>
{ function renderNode ( node : Node ) {
return (
< TreeItem
id = {node.id}
title = {node.name}
icon = {
node.children ? (
({ isExpanded }) =>
isExpanded ? < FolderOpenIcon /> : < FolderIcon />
) : (
< FileIcon />
)
}
suffix = {node.size}
>
{node.children && (
< Collection items = {node.children}>{renderNode}</ Collection >
)}
</ TreeItem >
);
}}
</ Tree >
);
}
< Tree aria-label = "Files" items = {files}>
{ function renderNode ( node ) {
return (
< TreeItem id = {node.id} title = {node.name}>
{node.children && < Collection items = {node.children}>{renderNode}</ Collection >}
</ TreeItem >
);
}}
</ Tree >
selectionMode="multiple" adds a checkbox to each row. Selecting a parent doesn't select its children; React Aria treats each row on its own, so select descendants yourself in onSelectionChange if your data needs it. isDisabled items can't be selected or expanded.
3 permissions selected. Repository admin needs an owner role.
import { useState } from "react" ;
import type { Selection } from "react-aria-components" ;
import { Tree, TreeItem } from "@/components/ui/tree" ;
export default function TreeSelection () {
const [ selected , setSelected ] = useState < Selection >(
new Set ([ "issues-read" , "issues-write" , "repo-read" ]),
);
const count = selected === "all" ? "All" : selected.size;
return (
< div className = "flex flex-col gap-3" >
< Tree
aria-label = "Token permissions"
selectionMode = "multiple"
selectedKeys = {selected}
onSelectionChange = {setSelected}
defaultExpandedKeys = {[ "repo" , "issues" ]}
variant = "bordered"
className = "w-80"
>
< TreeItem id = "repo" title = "Repository" >
< TreeItem id = "repo-read" title = "Read contents" />
< TreeItem id = "repo-write" title = "Push commits" />
< TreeItem id = "repo-admin" title = "Manage settings" isDisabled />
</ TreeItem >
< TreeItem id = "issues" title = "Issues" >
< TreeItem id = "issues-read" title = "Read issues" />
< TreeItem id = "issues-write" title = "Create and comment" />
</ TreeItem >
< TreeItem id = "deploy" title = "Deployments" >
< TreeItem id = "deploy-read" title = "View deployments" />
< TreeItem id = "deploy-run" title = "Trigger deploys" />
</ TreeItem >
</ Tree >
< p className = "text-muted-foreground text-sm" aria-live = "polite" >
{count} permission{count === 1 ? "" : "s" } selected. Repository admin
needs an owner role.
</ p >
</ div >
);
}
For a docs or project navigator, use selectionMode="single" with selectionBehavior="replace": clicking a row selects it with no checkbox, and the selected row shows the current page. disallowEmptySelection keeps one page selected. For links, give items an href instead and React Aria navigates on press (use your router's RouterProvider for client-side navigation).
Showing install . Click a page, or use the arrow keys and Enter.
import { BookOpenIcon, CodeIcon, LayersIcon, RocketIcon } from "lucide-react" ;
import { useState } from "react" ;
import { Tree, TreeItem } from "@/components/ui/tree" ;
export default function TreeNavigation () {
const [ page , setPage ] = useState ( "install" );
return (
< div className = "flex w-full max-w-xl gap-6" >
< Tree
aria-label = "Documentation"
selectionMode = "single"
selectionBehavior = "replace"
disallowEmptySelection
selectedKeys = {[page]}
onSelectionChange = {( keys ) => {
const [ key ] = [ ... keys];
if (key) setPage ( String (key));
}}
defaultExpandedKeys = {[ "start" , "guides" ]}
className = "w-56 shrink-0"
>
< TreeItem id = "start" title = "Getting started" icon = {< RocketIcon />}>
< TreeItem id = "install" title = "Installation" />
< TreeItem id = "theming" title = "Theming" />
</ TreeItem >
< TreeItem id = "guides" title = "Guides" icon = {< BookOpenIcon />}>
< TreeItem id = "forms" title = "Forms" />
< TreeItem id = "dark-mode" title = "Dark mode" />
< TreeItem id = "next" title = "Next.js" />
</ TreeItem >
< TreeItem id = "components" title = "Components" icon = {< LayersIcon />}>
< TreeItem id = "button" title = "Button" />
< TreeItem id = "tree" title = "Tree" />
</ TreeItem >
< TreeItem id = "api" title = "API reference" icon = {< CodeIcon />} />
</ Tree >
< div className = "hidden flex-1 rounded-lg border border-dashed p-4 text-muted-foreground text-sm sm:block" >
Showing < span className = "font-medium text-foreground" >{page}</ span >.
Click a page, or use the arrow keys and Enter.
</ div >
</ div >
);
}
expandedKeys and onExpandedChange control which items are open, for "Expand all" and "Collapse all" buttons or to remember the open folders. suffix holds an unread count here.
import { HashIcon, LockIcon, Volume2Icon } from "lucide-react" ;
import { useState } from "react" ;
import type { Key } from "react-aria-components" ;
import { Badge } from "@/components/ui/badge" ;
import { Button } from "@/components/ui/button" ;
import { Tree, TreeItem } from "@/components/ui/tree" ;
const sections = [ "general" , "product" , "voice" ];
export default function TreeControlled () {
const [ expanded , setExpanded ] = useState < Set < Key >>( new Set ([ "general" ]));
return (
< div className = "flex w-72 flex-col gap-2" >
< div className = "flex gap-1" >
< Button
variant = "ghost"
size = "xs"
onPress = {() => setExpanded ( new Set (sections))}
>
Expand all
</ Button >
< Button
variant = "ghost"
size = "xs"
onPress = {() => setExpanded ( new Set ())}
>
Collapse all
</ Button >
</ div >
< Tree
aria-label = "Channels"
expandedKeys = {expanded}
onExpandedChange = {setExpanded}
variant = "bordered"
>
< TreeItem id = "general" title = "General" >
< TreeItem
id = "announcements"
title = "announcements"
icon = {< HashIcon />}
/>
< TreeItem
id = "random"
title = "random"
icon = {< HashIcon />}
suffix = {
< Badge size = "sm" color = "brand" >
4
</ Badge >
}
/>
</ TreeItem >
< TreeItem id = "product" title = "Product" >
< TreeItem id = "design" title = "design" icon = {< HashIcon />} />
< TreeItem
id = "launch"
title = "launch-q3"
icon = {< LockIcon />}
suffix = {
< Badge size = "sm" color = "brand" >
12
</ Badge >
}
/>
</ TreeItem >
< TreeItem id = "voice" title = "Voice" >
< TreeItem id = "standup" title = "Daily standup" icon = {< Volume2Icon />} />
</ TreeItem >
</ Tree >
</ div >
);
}
The tree is a treegrid: one tab stop, with arrow keys inside. Give it an aria-label or aria-labelledby.
Each row announces its level, position ("3 of 5"), expanded state and selection.
title is the row's accessible name when it's a string. If title is JSX, pass textValue so type-ahead and screen readers get plain text.
The chevron and the selection checkbox are inside the row and aren't separate tab stops.
Icons are decorative; put the meaning in the title (or textValue), not only in a folder or lock icon.
Drag and drop isn't wired up in this component. React Aria's useDragAndDrop works with Tree; add a slot="drag" button to the item content if you need it.
Key Action ↓ / ↑ Next / previous visible row → Expands a collapsed row; on an expanded row, moves to its first child ← Collapses an expanded row; on a child, moves to its parent Home / End First / last visible row Enter Expands or collapses, or runs onAction / follows href Space Toggles selection Letters Type-ahead to a row by its text
On each TreeItem:
Attribute Present when data-expandedThe item is expanded data-has-child-itemsThe item has children data-levelAlways: the 1-based depth data-selectedThe item is selected data-focused / data-focus-visibleThe row has focus / keyboard focus data-hovered / data-pressedHovered / being pressed data-disabledisDisabled is set
Items set --tree-item-level, which the indent uses. Change the step by editing the indent span's width in the source.
Slot Element data-slot="tree"Root (with data-variant) data-slot="tree-item"A row data-slot="tree-item-content"The row's content data-slot="tree-item-icon"The icon data-slot="tree-item-title"The title data-slot="tree-item-suffix"The suffix
Also accepts every prop of React Aria's Tree .
Also accepts every prop of React Aria's TreeItem .
Grid List — a flat interactive list with the same row styles.
Sidebar — app navigation that can host a tree.
Disclosure — collapsible content sections.
PreviousTable Next Tag Group