Tree View#

The WAI-ARIA tree pattern: a role="tree" of treeitem nodes, leaves as items and branches wrapping a role="group" subtree. Selection and expansion are separate acts on separate models — the unnamed model is the selected value, model:expandedValues the set of open branches — and the keyboard walks only the nodes that are visible.

Import#

TSX
import { TreeView } from '@sigx/zero/tree-view';

TreeView is a compound: TreeView.Root, TreeView.Label, TreeView.Tree, TreeView.Item, TreeView.Branch, TreeView.BranchTrigger, TreeView.BranchIndicator, TreeView.BranchContent. It is also re-exported from the @sigx/zero root, together with treeViewAnatomy, useTreeViewContext and useTreeBranchContext.

Usage#

TSX
import { component } from 'sigx';
import { TreeView } from '@sigx/zero/tree-view';

const Files = component(({ signal }) => {
    const state = signal({ selected: 'README.md', expanded: ['src'] });

    return () => (
        <TreeView.Root model={() => state.selected} model:expandedValues={() => state.expanded}>
            <TreeView.Label>Files</TreeView.Label>
            <TreeView.Tree>
                <TreeView.Branch value="src">
                    <TreeView.BranchTrigger>
                        <TreeView.BranchIndicator />
                        src
                    </TreeView.BranchTrigger>
                    <TreeView.BranchContent>
                        <TreeView.Item value="src/index.ts">index.ts</TreeView.Item>
                        <TreeView.Item value="src/app.tsx">app.tsx</TreeView.Item>
                    </TreeView.BranchContent>
                </TreeView.Branch>
                <TreeView.Item value="README.md">README.md</TreeView.Item>
            </TreeView.Tree>
        </TreeView.Root>
    );
});

model={() => state.selected} binds the selected node's value both ways; model:expandedValues={() => state.expanded} binds the array of open branch values. Leave either off and pass defaultValue / defaultExpandedValues to keep that state inside the component; valueChange and expandedChange fire either way. See Models for the named-models convention.

The Label names the tree: TreeView.Tree renders aria-labelledby pointing at it. Branches nest to any depth — a Branch inside a BranchContent is a level deeper, and aria-level is computed for every node from that nesting.

Selecting a branch#

A branch is a treeitem too: Enter or Space on a focused branch selects it, exactly as on a leaf, and its branch and branch-trigger parts carry data-selected. Clicking the trigger row toggles expansion without selecting — pointer and keyboard both keep the two acts apart.

Rendering a node as your own element#

TSX
<TreeView.Item value="README.md" asChild>
    {(p) => <a href="/files/README.md" {...p}>README.md</a>}
</TreeView.Item>

Item and BranchTrigger accept asChild; the default slot receives the part's attribute bag, and spreading it puts the role, tabIndex, ARIA and keyboard wiring on your element.

Anatomy#

PartElementStatesFlagsNotes
rootdivdisabledCarries the variant axes.
labeldivThe tree's accessible name. Inside root.
treedivrole="tree", aria-labelledby the label. Inside root.
itemdivselected, disabled, focus-visible, pressed, press-animatingA leaf: role="treeitem", aria-selected, aria-level. Publishes press feedback. asChild.
branchdivopen | closedselected, disabledThe branch treeitem element: aria-expanded, aria-selected, aria-level; owns focus.
branch-triggerdivopen | closedselected, disabled, focus-visible, pressed, press-animatingThe clickable row; mirrors the branch's state and focus. asChild.
branch-indicatorspanopen | closedaria-hidden glyph, default . Inside branch-trigger.
branch-contentdivopen | closedrole="group" holding the subtree. Hidden in closed (hiddenIn).

Every part carries data-scope="tree-view" and data-part="<part>". The branch is the treeitem element because it wraps its group — that is where aria-expanded and aria-level sit in the APG shape, and it is the element that takes focus. The branch-trigger mirrors the branch's data-state, data-selected and data-focus-visible so a recipe rings the row, never the whole subtree. There is no aria-posinset / aria-setsize: the full tree is in the DOM under proper role="group" nesting, so assistive technology computes them. See The anatomy contract.

A collapsed branch-content gets the hidden attribute from the runtime — its nodes keep their registration and simply stop being visible to navigation — so [data-state="closed"] on that part can never paint.

Props#

TreeView.Root#

PropTypeDefaultDescription
modelstringTwo-way binding of the selected node's value.
defaultValuestringInitial selection when uncontrolled.
valueChangeevent (value: string)Fires whenever the selection changes.
model:expandedValuesstring[]Two-way binding of the open branches' values.
defaultExpandedValuesstring[][]Initial open branches when uncontrolled.
expandedChangeevent (values: string[])Fires whenever a branch opens or closes.
disabledbooleanfalseDisables every node; renders data-disabled.
color / size / variant / axes / modsdesign-system vocabularyThe variant axes, rendered as data-* on root.
classstringExtra classes on the root element.

TreeView.Label, TreeView.Tree, TreeView.BranchIndicator, TreeView.BranchContent#

Only class. The indicator's default slot replaces the glyph.

TreeView.Item#

PropTypeDefaultDescription
valuestringrequiredThe value this leaf selects.
disabledbooleanfalseSkipped by roving focus; renders data-disabled, aria-disabled.
asChildbooleanfalseRender through the default slot, which receives the part bag.
classstringExtra classes.

TreeView.Branch#

PropTypeDefaultDescription
valuestringrequiredThe branch's value — selectable, and the key in expandedValues.
disabledbooleanfalseSkipped by roving focus; renders data-disabled, aria-disabled.
classstringExtra classes.

TreeView.BranchTrigger#

PropTypeDefaultDescription
asChildbooleanfalseRender through the default slot, which receives the part bag.
classstringExtra classes.

Keyboard#

KeyAction
ArrowDown / ArrowUpMove focus to the next / previous visible enabled node.
Home / EndFirst / last visible enabled node.
ArrowRight (ArrowLeft under RTL)On a closed branch, expand it; on an open branch, move focus to its first enabled child.
ArrowLeft (ArrowRight under RTL)On an open branch, collapse it; otherwise move focus to the parent branch.
Enter / SpaceSelect the focused node — expansion is unchanged.
Printable charactersTypeahead to the next visible node whose accessible text starts with the typed prefix.

One tab stop: the selected node while it is visible and enabled, otherwise the first visible enabled node — a selection hidden under a collapsed branch never leaves the tree unreachable. Typeahead matches a branch by the visible text of its trigger row, skipping aria-hidden decoration, so the default indicator never shadows the label the user reads. Selection is single.

In the shipped design systems#

Both @sigx/zero-basic and @sigx/zero-daisyui wire color (the eight recommended roles) and size (xsxl) on the tree-view root, so <TreeView.Root color="primary" size="sm"> is styled in both. Neither wires a variant or any mods on the scope; under a design system's /register import those props are therefore absent. See Typed vocabulary.

A recipe rule that every design system must follow: never set display on branch-content unconditionally. The hidden attribute is what collapses the subtree, and the zero.structure layer enforces it, so an unconditional display: block (or flex, grid) on the part would fight the runtime and leave a closed branch open. Write the layout as &:not([hidden]) — the same rule as Avatar's hidden parts.

Accordion for open / closed sections without a selection model, Menu for the other nested-navigation composite.