Tree list
An expandable tree with real tree roles and full arrow key support.
npx shadcn@latest add https://ui.goodtabs.co/r/tree-list.json
With the @goodtabs namespace in components.json: npx shadcn@latest add @goodtabs/tree-list
- app
- layout.tsx1 KB
- page.tsx3 KB
- components
- package.json1 KB
Two folders and a file. Click or press Enter to select, Right to open.
Deep nesting
- registry
- goodtabs
- stat-card
- stat-card.tsx
- meta.json
- demos
- default.tsx
- row.tsx
- hero
- README.md
- scripts
Five levels. Left on a child moves to its parent.
Usage
import { TreeList } from "@/components/ui/tree-list"
<TreeList aria-label="Files" nodes={nodes} selected={id} onSelect={setId} />Props
| Prop | Type | Default | Description |
|---|---|---|---|
| nodes | TreeNode[] | Nodes with id, label, optional meta and children. | |
| defaultExpanded | string[] | [] | Branch ids that start open. |
| selected | string | Id of the selected node. | |
| onSelect | (id: string) => void | Called when a node is selected. | |
| aria-label | string | "Tree" | Names the tree for screen readers. |
Source
tree-list/tree-list.tsx
"use client";
import * as React from "react";
import { ChevronRightIcon, FileIcon, FolderIcon } from "lucide-react";
import { cn } from "@/lib/utils";
export type TreeNode = {
id: string;
label: string;
/** Child nodes. A node with children is a branch. */
children?: TreeNode[];
/** Small mono text on the right, for example a size or a count. */
meta?: string;
};
type TreeListProps = Omit<React.ComponentProps<"ul">, "children" | "onSelect"> & {
nodes: TreeNode[];
/** Branch ids that start open. */
defaultExpanded?: string[];
/** Id of the selected node. */
selected?: string;
onSelect?: (id: string) => void;
};
type Flat = { node: TreeNode; level: number; parent?: string; index: number; size: number };
function flatten(nodes: TreeNode[], open: Set<string>, level = 1, parent?: string, out: Flat[] = []) {
nodes.forEach((node, index) => {
out.push({ node, level, parent, index, size: nodes.length });
if (node.children?.length && open.has(node.id)) flatten(node.children, open, level + 1, node.id, out);
});
return out;
}
/**
* An expandable tree with the tree pattern's roles and keys. One tab stop.
* Up and Down move, Right opens or enters, Left closes or goes to the parent,
* Home and End jump, Enter and Space select.
*/
export function TreeList({
nodes,
defaultExpanded = [],
selected,
onSelect,
className,
"aria-label": ariaLabel = "Tree",
...props
}: TreeListProps) {
const [open, setOpen] = React.useState(() => new Set(defaultExpanded));
const [active, setActive] = React.useState(selected ?? nodes[0]?.id);
const refs = React.useRef(new Map<string, HTMLLIElement>());
const rows = flatten(nodes, open);
// The active id can sit in a collapsed branch or name no node. The tree still needs its one tab stop.
const tabStop = rows.some((row) => row.node.id === active) ? active : rows[0]?.node.id;
function focusId(id: string | undefined) {
if (!id) return;
setActive(id);
refs.current.get(id)?.focus();
}
function toggle(id: string, next: boolean) {
setOpen((prev) => {
const copy = new Set(prev);
if (next) copy.add(id);
else copy.delete(id);
return copy;
});
}
function onKeyDown(event: React.KeyboardEvent, i: number) {
const row = rows[i];
const isBranch = !!row.node.children?.length;
const expanded = open.has(row.node.id);
switch (event.key) {
case "ArrowDown":
focusId(rows[i + 1]?.node.id);
break;
case "ArrowUp":
focusId(rows[i - 1]?.node.id);
break;
case "Home":
focusId(rows[0].node.id);
break;
case "End":
focusId(rows[rows.length - 1].node.id);
break;
case "ArrowRight":
if (isBranch && !expanded) toggle(row.node.id, true);
else if (isBranch) focusId(rows[i + 1]?.node.id);
break;
case "ArrowLeft":
if (isBranch && expanded) toggle(row.node.id, false);
else focusId(row.parent);
break;
case "Enter":
case " ":
if (isBranch) toggle(row.node.id, !expanded);
onSelect?.(row.node.id);
break;
default:
return;
}
event.preventDefault();
event.stopPropagation();
}
return (
<ul
role="tree"
data-slot="tree-list"
aria-label={ariaLabel}
className={cn("m-0 flex w-full list-none flex-col gap-px p-0 text-sm", className)}
{...props}
>
{rows.map(({ node, level, index, size }, i) => {
const isBranch = !!node.children?.length;
const expanded = open.has(node.id);
const isSelected = node.id === selected;
const Icon = isBranch ? FolderIcon : FileIcon;
return (
<li
key={node.id}
ref={(el) => {
if (el) refs.current.set(node.id, el);
else refs.current.delete(node.id);
}}
role="treeitem"
aria-level={level}
aria-setsize={size}
aria-posinset={index + 1}
aria-expanded={isBranch ? expanded : undefined}
aria-selected={isSelected}
tabIndex={node.id === tabStop ? 0 : -1}
onKeyDown={(e) => e.target === e.currentTarget && onKeyDown(e, i)}
onClick={(e) => {
e.stopPropagation();
setActive(node.id);
if (isBranch) toggle(node.id, !expanded);
onSelect?.(node.id);
}}
className={cn(
"flex h-7 cursor-default items-center gap-1.5 rounded-sm pr-2 transition-colors duration-100 ease-out outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring/50",
isSelected ? "bg-muted text-foreground" : "text-foreground/80",
)}
style={{ paddingLeft: `${(level - 1) * 16 + 4}px` }}
>
<ChevronRightIcon
aria-hidden="true"
className={cn(
"size-3.5 shrink-0 text-muted-foreground transition-transform duration-150 ease-out",
!isBranch && "invisible",
expanded && "rotate-90",
)}
/>
<Icon aria-hidden="true" className="size-3.5 shrink-0 text-muted-foreground" />
<span className="min-w-0 flex-1 truncate">{node.label}</span>
{node.meta && <span className="font-mono text-[11px] text-muted-foreground">{node.meta}</span>}
</li>
);
})}
</ul>
);
}
For AI agents
Hand your agent this page, the markdown at /components/tree-list/prompt.md, or the Copy AI prompt button above.
When to use
- File and folder browsers
- Any hierarchy the user selects from, not navigates
When not to use
- Site navigation made of links: use a nav tree
- Flat lists, where a listbox is simpler
Agent notes
- Roles are tree, treeitem with aria-level, aria-setsize, aria-posinset and aria-expanded
- One tab stop with a roving tabindex. Up, Down, Left, Right, Home, End, Enter and Space are handled
- Selection is controlled through selected; expansion is internal and seeded by defaultExpanded
- Ids must be unique across the whole tree
- It takes a function, so a demo that passes onSelect must be a client component