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 }
Read the level and position beside each section. Setup and Hello are siblings under Basics; Extras is a branch with no loaded children.

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.

Console
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,
}
Open lib, then choose date.ts; the console logs its path. Close src to hide its descendants, or use Expand All to reveal every folder.

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 }
Edit the paths, one per line. Folders appear before files; add a trailing slash to create an empty folder.

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 }
Open Guides to reveal its rows. The labels show each row's index and parent; Old setup remains unavailable.

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

valuestring
Identifies the item. Unique in the tree.
Example: Tree Data and File Paths
labelstring
What it shows, and what typeahead matches.
Example: Tree Data and File Paths
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
href?string
Makes the item a link to this address.
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".

Console
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,
}
Check a folder to check its contents. Uncheck one file to make its folder mixed, then change the strategy and compare the values in the console.

Strategies and Locked Items

The strategy controls how the checked value is represented:

StrategyValue
"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.

Console
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,
}
Check Photos to select its editable files, then press it again to clear them. Try each strategy; the locked file stays unchecked.

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"]') === el

Use 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.