Work with nested data, show open branches, and calculate checked states.
Use these helpers to look up parents and children, find visible rows, and calculate checked states across a tree.
They work with plain data, so you can use them in components, on the server, or in tests.
A Tree's Shape
Levels and Places
Give createTreeCollection a list of nodes, with parents before children and siblings in display order:
import { createTreeCollection } from "@ui-sdk/core"
const outline = createTreeCollection([
{ key: "basics", parent: null },
{ key: "setup", parent: "basics" },
{ key: "hello", parent: "basics" },
{ key: "extras", parent: null, branch: true },
])
outline.parent("hello") // "basics".
outline.level("hello") // 2.
outline.position("hello") // { index: 2, size: 2 }.level and position count from one, matching aria-level, aria-posinset and aria-setsize. A node with children is a branch. Use branch: true for an empty folder or children you'll load later.
import * as React from "react"
import { createTreeCollection } from "@ui-sdk/core"
const titles: Record<string, string> = {
basics: "Basics",
setup: "Setup",
hello: "Hello world",
layout: "Layout",
grids: "Grids",
extras: "Extras",
}
const outline = createTreeCollection([
{ key: "basics", parent: null },
{ key: "setup", parent: "basics" },
{ key: "hello", parent: "basics" },
{ key: "layout", parent: null },
{ key: "grids", parent: "layout" },
{ key: "extras", parent: null, branch: true },
])
function Example() {
return (
<div data-slot="tree-behavior-list">
{outline.keys().map((key) => {
const { index, size } = outline.position(key)
const parent = outline.parent(key)
return (
<div
key={key}
data-section={key}
data-slot="tree-behavior-row"
data-branch={outline.isBranch(key) ? "" : undefined}
style={{
paddingInlineStart: `${(outline.level(key) - 1) * 16 + 8}px`,
}}
>
<span>
{outline.isBranch(key) ? "▸ " : ""}
{titles[key]}
</span>
<span data-slot="hint">
Level {outline.level(key)}, {index} of {size}
{parent ? ` in ${titles[parent]}` : ""}
</span>
</div>
)
})}
<p data-slot="output">
{outline.size} sections
{outline.has("appendix") ? ", with an appendix" : ", no appendix yet"}
</p>
</div>
)
}
export { Example }outline.size counts nodes, has(key) checks whether one exists, and isBranch(key) identifies branches. A node whose parent isn't in the collection is treated as a top-level node.
Open and Closed Branches
Keep the open branch keys in your own state. visible returns the keys whose ancestors are open:
outline.visible([]) // ["basics", "extras"].
outline.visible(["basics"]) // ["basics", "setup", "hello", "extras"].It reuses the result for the same expanded array. Replace that array when the open branches change.
import * as React from "react"
import { createTreeCollection, definePrimitive } from "@ui-sdk/core"
import { createParts } from "@ui-sdk/react/primitive"
import { ChevronRight, File, Folder } from "lucide-react"
import { useLog } from "./log"
const files = createTreeCollection([
{ key: "src", parent: null },
{ key: "src/lib", parent: "src" },
{ key: "src/lib/date.ts", parent: "src/lib" },
{ key: "src/lib/math.ts", parent: "src/lib" },
{ key: "src/index.ts", parent: "src" },
{ key: "docs", parent: null },
{ key: "docs/intro.md", parent: "docs" },
{ key: "README.md", parent: null },
])
type FilesOptions = { onPick?: (key: string) => void }
type FilesState = { expanded: string[]; picked: string | null }
const fileList = definePrimitive({
name: "file-list",
options: { onPick: "callback" },
state: (_options: FilesOptions): FilesState => ({
expanded: ["src"],
picked: null,
}),
api: (context) => {
const { store } = context
return {
toggle(key: string) {
const { expanded } = store.get()
if (!files.isBranch(key)) {
store.set({ picked: key })
context.options().onPick?.(key)
return
}
store.set({
expanded: expanded.includes(key)
? expanded.filter((other) => other !== key)
: [...expanded, key],
})
},
expandAll: () =>
store.set({
expanded: files.keys().filter((key) => files.isBranch(key)),
}),
}
},
slots: (slot) => ({
root: slot<{}, { shown: readonly string[] }>({
state: (state) => ({ shown: files.visible(state.expanded) }),
}),
row: slot<{ value: string }>({
props: { value: "string" },
attrs: (state, { key }) => ({
type: "button",
"aria-expanded": files.isBranch(key)
? String(state.expanded.includes(key))
: undefined,
"aria-current": state.picked === key ? "true" : undefined,
style: `--tree-level:${files.level(key)}`,
}),
press: ({ api, key }) => api.toggle(key),
}),
expand: slot({
attrs: () => ({ type: "button" }),
press: ({ api }) => api.expandAll(),
}),
}),
})
const parts = createParts(fileList, {
root: "div",
row: "button",
expand: "button",
})
const FileList = parts.Root
const FileRow = parts.Row
const FileExpandAll = parts.Expand
const name = (key: string) => key.split("/").at(-1)
const fileCount = (key: string) => {
const count = files
.descendants(key)
.filter((below) => !files.isBranch(below)).length
return `${count} ${count === 1 ? "file" : "files"}`
}
function Example() {
const log = useLog()
return (
<FileList
onPick={(key: string) =>
log([...files.ancestors(key), key].map(name).join(" / "))
}
>
{({ shown }) => (
<div data-slot="stack">
<div data-slot="controls">
<FileExpandAll data-size="sm">Expand All</FileExpandAll>
</div>
<div data-slot="tree-behavior-list">
{shown.map((key) => (
<FileRow key={key} value={key}>
{files.isBranch(key) ? (
<>
<ChevronRight aria-hidden="true" />
<Folder aria-hidden="true" />
</>
) : (
<File aria-hidden="true" />
)}
<span>{name(key)}</span>
{files.isBranch(key) && (
<span data-slot="hint">{fileCount(key)}</span>
)}
</FileRow>
))}
</div>
</div>
)}
</FileList>
)
}
export {
Example,
fileList,
FileExpandAll,
FileList,
FileRow,
type FilesOptions,
type FilesState,
}Use children(key) for immediate children, or children(null) for the top level. descendants(key) includes everything below a node; ancestors(key) returns its path from the outermost parent inward. keys() lists the whole collection in tree order.
Tree Data and File Paths
If your data is already nested, treeDataNodes converts it to collection nodes:
import { createTreeCollection, treeDataNodes } from "@ui-sdk/core"
const items = [
{
value: "src",
label: "src",
children: [{ value: "index", label: "index.ts" }],
},
]
const tree = createTreeCollection(treeDataNodes(items))Each node keeps the original item. An item with children, even an empty array, is a branch. Leave children out for a leaf.
For file paths, treeFromPaths builds the nested data for you:
import { treeFromPaths } from "@ui-sdk/core"
const items = treeFromPaths([
"src/index.ts",
"src/lib/util.ts",
"README.md",
"assets/",
])import * as React from "react"
import {
createTreeCollection,
treeDataNodes,
treeFromPaths,
} from "@ui-sdk/core"
import { Field, FieldControl, FieldLabel } from "@ui-sdk/react/field"
import { File, Folder } from "lucide-react"
function Example() {
const [text, setText] = React.useState(
"src/index.ts\nsrc/lib/util.ts\nREADME.md\nassets/"
)
const items = treeFromPaths(text.split("\n").filter(Boolean))
const nodes = treeDataNodes(items)
const tree = createTreeCollection(nodes)
return (
<div data-slot="stack">
<Field>
<FieldLabel>Paths</FieldLabel>
<FieldControl
render={
<textarea
rows={4}
value={text}
onChange={(event) => setText(event.target.value)}
/>
}
/>
</Field>
<div data-tree data-slot="tree-behavior-list">
{nodes.map(({ key, item }) => (
<span
key={key}
data-slot="tree-behavior-row"
style={{ "--tree-level": tree.level(key) } as React.CSSProperties}
>
{item.children ? (
<Folder aria-hidden="true" />
) : (
<File aria-hidden="true" />
)}
<span>{item.label}</span>
</span>
))}
</div>
</div>
)
}
export { Example }Flat Rows
You can render nested data as a flat list. visibleTreeRows returns each visible item with its parent, level and row index:
import { visibleTreeRows } from "@ui-sdk/core"
const rows = visibleTreeRows(items, ["src"])level starts at one; index starts at zero. Use them to indent rows or connect a virtualized list.
import * as React from "react"
import { visibleTreeRows, type TreeItemData } from "@ui-sdk/core"
const items: TreeItemData[] = [
{
value: "start",
label: "Getting started",
children: [
{ value: "install", label: "Install", href: "#install" },
{ value: "old-setup", label: "Old setup", disabled: true },
],
},
{
value: "guides",
label: "Guides",
children: [{ value: "theming", label: "Theming", href: "#theming" }],
},
{ value: "faq", label: "FAQ", href: "#faq" },
]
function Example() {
const [expanded, setExpanded] = React.useState<string[]>(["start"])
const rows = visibleTreeRows(items, expanded)
const toggle = (value: string) =>
setExpanded((current) =>
current.includes(value)
? current.filter((other) => other !== value)
: [...current, value]
)
return (
<div data-slot="tree-behavior-list">
{rows.map(({ item, level, index, parent }) => {
const style = { paddingInlineStart: `${(level - 1) * 16 + 8}px` }
const note = (
<span data-slot="hint">
row {index + 1}
{parent ? `, in ${parent}` : ""}
</span>
)
return item.children ? (
<button
key={item.value}
type="button"
aria-expanded={expanded.includes(item.value)}
onClick={() => toggle(item.value)}
data-slot="tree-behavior-row"
style={style}
>
{item.label}
{note}
</button>
) : item.href ? (
<a
key={item.value}
href={item.href}
data-slot="tree-behavior-row"
style={style}
>
{item.label}
{note}
</a>
) : (
<span
key={item.value}
aria-disabled={item.disabled}
data-slot="tree-behavior-row"
style={style}
>
{item.label}
{note}
</span>
)
})}
</div>
)
}
export { Example }The helper supplies data. Your renderer decides how to handle it: this demo renders href items as links and an unavailable item as a non-interactive row. In a tree component, a disabled item can still receive focus while selection and activation are blocked.
Data Fields
children?TreeItemData[]- Its children. Leave it out for a leaf. An empty array is a branch with no children, or one whose children load when it opens.
- Example: Tree Data and File Paths
disabled?boolean- Whether it can't be selected, checked or activated. It can still take focus.
- Example: Flat Rows
Checkboxes
Check a Branch
treeCheckedStates calculates whether each node is checked, unchecked or mixed. toggleTreeChecked returns the next checked value:
import {
createTreeCollection,
toggleTreeChecked,
treeCheckedStates,
} from "@ui-sdk/core"
const tree = createTreeCollection([
{ key: "fruit", parent: null },
{ key: "apple", parent: "fruit" },
{ key: "pear", parent: "fruit" },
])
const checked = treeCheckedStates(tree, ["apple"])
checked("fruit") // "indeterminate".
toggleTreeChecked(tree, ["apple"], "fruit") // ["apple", "pear"].Calculate the states once for a checked value, then read each node with checked(key). Render "indeterminate" as aria-checked="mixed".
import * as React from "react"
import {
createTreeCollection,
definePrimitive,
toggleTreeChecked,
treeCheckedStates,
treeDataNodes,
type TreeCheckStrategy,
type TreeItemData,
} from "@ui-sdk/core"
import { createParts } from "@ui-sdk/react/primitive"
import { useLog } from "./log"
type CheckTreeOptions = {
items: TreeItemData[]
strategy: TreeCheckStrategy
onCheckedChange?: (checked: string[]) => void
}
type CheckTreeState = { checked: string[]; strategy: TreeCheckStrategy }
type CheckTreeItemState = { checked: boolean | "indeterminate"; level: number }
const checkTree = definePrimitive({
name: "check-tree",
options: { items: "value", strategy: "string", onCheckedChange: "callback" },
state: (options: CheckTreeOptions): CheckTreeState => ({
checked: [],
strategy: options.strategy,
}),
sync: (options) => ({ strategy: options.strategy }),
api: (context) => {
const { store } = context
let cache:
| { items: TreeItemData[]; tree: ReturnType<typeof createTreeCollection> }
| undefined
const tree = () => {
const { items } = context.options()
if (cache?.items !== items) {
cache = { items, tree: createTreeCollection(treeDataNodes(items)) }
}
return cache.tree
}
let states: {
checked: string[]
strategy: TreeCheckStrategy
read: (key: string) => boolean | "indeterminate"
}
const stateOf = (state: CheckTreeState, key: string) => {
if (
states?.checked !== state.checked ||
states.strategy !== state.strategy
) {
states = {
...state,
read: treeCheckedStates(tree(), state.checked, state.strategy),
}
}
return states.read(key)
}
return {
tree,
stateOf,
toggle(key: string) {
const { checked, strategy } = store.get()
const next = toggleTreeChecked(tree(), checked, key, { strategy })
store.set({ checked: next })
context.options().onCheckedChange?.(next)
},
}
},
slots: (slot) => ({
root: slot({ attrs: () => ({ role: "group" }) }),
item: slot<{ value: string }, CheckTreeItemState>({
props: { value: "string" },
state: (state, { api, key }) => ({
checked: api.stateOf(state, key),
level: api.tree().level(key),
}),
attrs: (state, { api, key }) => {
const checked = api.stateOf(state, key)
return {
type: "button",
role: "checkbox",
"aria-checked":
checked === "indeterminate" ? "mixed" : String(checked),
"data-checked": checked === true ? "" : undefined,
"data-indeterminate": checked === "indeterminate" ? "" : undefined,
style: `padding-inline-start:${(api.tree().level(key) - 1) * 20}px`,
}
},
press: ({ api, key }) => api.toggle(key),
}),
}),
})
const parts = createParts(checkTree, { root: "div", item: "button" })
const CheckTree = parts.Root
const CheckTreeItem = parts.Item
const items: TreeItemData[] = [
{
value: "src",
label: "src",
children: [
{ value: "src/index.ts", label: "index.ts" },
{ value: "src/util.ts", label: "util.ts" },
],
},
{ value: "README.md", label: "README.md" },
]
const flat = (list: TreeItemData[]): TreeItemData[] =>
list.flatMap((item) => [item, ...flat(item.children ?? [])])
function Example() {
const log = useLog()
const [strategy, setStrategy] = React.useState<TreeCheckStrategy>("leaf")
return (
<div data-slot="stack">
<div data-slot="controls">
<div
role="radiogroup"
aria-label="Strategy"
data-slot="check-tree-strategy"
>
{(["leaf", "trunk"] as const).map((option) => (
<button
key={option}
type="button"
role="radio"
aria-checked={strategy === option}
data-size="sm"
onClick={() => setStrategy(option)}
>
{option[0].toUpperCase() + option.slice(1)}
</button>
))}
</div>
</div>
<CheckTree
items={items}
strategy={strategy}
aria-label="Files"
onCheckedChange={(checked: string[]) =>
log(`Value: ${checked.join(", ") || "none"}`)
}
>
{flat(items).map((item) => (
<CheckTreeItem
key={item.value}
value={item.value}
aria-label={item.label}
>
<span data-slot="check-tree-box">
<span data-show="checked">✓</span>
<span data-show="indeterminate">–</span>
</span>
{item.label}
</CheckTreeItem>
))}
</CheckTree>
</div>
)
}
export {
checkTree,
CheckTree,
CheckTreeItem,
Example,
type CheckTreeItemState,
type CheckTreeOptions,
type CheckTreeState,
}Strategies and Locked Items
The strategy controls how the checked value is represented:
| Strategy | Value |
|---|---|
"leaf" | Checked leaves. This is the default. |
"trunk" | Outermost fully checked nodes, so a checked folder can stand for its contents. |
"branch" | Every fully checked node, including branches and leaves. |
"independent" | Each checked node on its own, with no cascading or mixed state. |
Pass the same strategy when calculating states and toggling a node:
const checked = treeCheckedStates(tree, value, "trunk")
const next = toggleTreeChecked(tree, value, "fruit", { strategy: "trunk" })For the cascading strategies, a branch in the value stands for its leaves. That lets a value remain meaningful after changing strategies or loading children.
import * as React from "react"
import {
createTreeCollection,
definePrimitive,
toggleTreeChecked,
treeCheckedStates,
treeDataNodes,
type TreeCheckStrategy,
type TreeItemData,
} from "@ui-sdk/core"
import { createParts } from "@ui-sdk/react/primitive"
import { useLog } from "./log"
const items: TreeItemData[] = [
{
value: "photos",
label: "Photos",
children: [
{ value: "beach.jpg", label: "beach.jpg" },
{ value: "city.jpg", label: "city.jpg" },
{ value: "locked.jpg", label: "locked.jpg", disabled: true },
],
},
{ value: "notes.txt", label: "notes.txt" },
]
const nodes = treeDataNodes(items)
const tree = createTreeCollection(nodes)
const itemOf = new Map(nodes.map((node) => [node.key, node.item]))
type BackupOptions = {
strategy: TreeCheckStrategy
onCheckedChange?: (checked: string[]) => void
}
type BackupState = { checked: string[]; strategy: TreeCheckStrategy }
const backup = definePrimitive({
name: "backup",
options: { strategy: "string", onCheckedChange: "callback" },
state: (options: BackupOptions): BackupState => ({
checked: [],
strategy: options.strategy,
}),
sync: (options) => ({ strategy: options.strategy }),
api: (context) => {
const { store } = context
return {
toggle(key: string) {
const { checked, strategy } = store.get()
const next = toggleTreeChecked(tree, checked, key, {
strategy,
checkable: (node) => !itemOf.get(node)?.disabled,
})
store.set({ checked: next })
context.options().onCheckedChange?.(next)
},
}
},
slots: (slot) => ({
root: slot({ attrs: () => ({ role: "group" }) }),
item: slot<{ value: string }>({
props: { value: "string" },
attrs: (state, { key }) => {
const checked = treeCheckedStates(
tree,
state.checked,
state.strategy
)(key)
return {
type: "button",
role: "checkbox",
"aria-checked":
checked === "indeterminate" ? "mixed" : String(checked),
"aria-disabled": itemOf.get(key)?.disabled ? "true" : undefined,
style: `padding-inline-start:${(tree.level(key) - 1) * 20}px`,
}
},
press: ({ api, key }) => api.toggle(key),
}),
}),
})
const parts = createParts(backup, { root: "div", item: "button" })
const Backup = parts.Root
const BackupItem = parts.Item
const strategies: TreeCheckStrategy[] = [
"leaf",
"trunk",
"branch",
"independent",
]
function Example() {
const log = useLog()
const [strategy, setStrategy] = React.useState<TreeCheckStrategy>("leaf")
return (
<div data-slot="stack">
<div data-slot="controls">
<div
role="radiogroup"
aria-label="Strategy"
data-slot="tree-behavior-strategies"
>
{strategies.map((option) => (
<button
key={option}
type="button"
role="radio"
aria-checked={strategy === option}
data-size="sm"
onClick={() => setStrategy(option)}
>
{option[0].toUpperCase() + option.slice(1)}
</button>
))}
</div>
</div>
<Backup
strategy={strategy}
aria-label="Backup"
onCheckedChange={(checked: string[]) =>
log(`Value: ${checked.join(", ") || "none"}`)
}
>
{tree.keys().map((key) => (
<BackupItem key={key} value={key} aria-label={itemOf.get(key)!.label}>
<span data-slot="tree-behavior-checkbox">
<span data-show="checked">✓</span>
<span data-show="mixed">–</span>
</span>
{itemOf.get(key)!.label}
</BackupItem>
))}
</Backup>
</div>
)
}
export {
backup,
Backup,
BackupItem,
type BackupOptions,
type BackupState,
Example,
}To lock nodes, pass checkable. A refused node keeps its state, and a refused branch protects its descendants:
toggleTreeChecked(tree, value, key, {
strategy,
checkable: (node) => !locked.includes(node),
})A folder can remain mixed because of a locked file. Pressing it again unchecks its editable files when those are already checked.
Nested Parts
Events from a nested item bubble through its parents. Before a parent handles one, check that the closest tree item is its own element:
const own = (el: HTMLElement | undefined, event?: Event) =>
!el ||
!(event?.target instanceof Element) ||
event.target.closest('[role="treeitem"]') === elUse that check in the item's handlers. For example:
press: ({ api, key, el }, event) => own(el, event) && api.press(key, event),For a virtualized tree, see rows that aren't rendered to move focus to an item as it appears.