Tree
A hierarchical, keyboard-navigable list of expandable nodes built from a data array.
Live demo
Tree renders a nested hierarchy from an items array. Each node carries a label, an optional value, href, and children, plus flags for expanded, selected, and disabled.
It builds the WAI-ARIA tree pattern: a role="tree" with nested role="group" levels and role="treeitem" nodes carrying aria-level, aria-expanded, and aria-selected, with a single roving tab stop so the whole tree is one Tab stop and the arrow keys walk it. A twisty rotates on expand. A node with an href renders its row as a link whether or not it has children, so a branch can be both navigable and a group; the row navigates while the twisty (and Left/Right) work the children. A branch without an href is a pure container that toggles on click. A node also carries per-row trailing content: one or more badge pills (a count plus a toned status pill after the label) and actions (hover-revealed row buttons that fire a tree-action event, each optionally disabled in place), the file-tree-with-inline-controls shape. Being data-driven keeps it robust across the bindings and a natural fit for a file or navigation tree. Three sizes (sm, md, lg) scale the row density.
Expansion, selection, and the roving tab stop are host-owned: the node flags only seed them, and an items re-assignment reconciles rather than resets. So a live data feed (a per-row word count that ticks on every keystroke) can re-set items as often as it likes without re-expanding a branch the user collapsed or moving the tab stop. A key that disappears is dropped; a genuinely new branch seeds from its own expanded / locked flag. Call resetState() for the deliberate clean slate — loading a different document, not repainting the same one.
When to use
How this component composes with the rest of the set.
Props
4 props, straight from the manifest.
| Prop | Type | Default | Bindings | Description |
|---|---|---|---|---|
Appearance
Sizes
sm
Compact rows.
md
Default.
lg
Roomy rows.
States
selected
The chosen node: its row takes the accent tint under the primary ink. When the theme's --selection-cue resolves to marker (a high-contrast or redundant-cues algorithm), a non-color check glyph is added so selection never rests on color alone.
row-hover
Pointer over a row: the hover tint.
expanded
An open parent: the twisty rotates and the child group shows.
focus-visible
Keyboard focus on a node: an inset token ring on its row.
disabled
A locked node: muted and non-interactive.
Anatomy
The named parts that make up the component, with their selectors.
tree
The role="tree" root list holding the top-level nodes.
row
A node's clickable row, indented by its level; a <div>, or an <a> when the node has an href.
twisty
The disclosure caret on parent nodes; rotates 90° when the node is expanded, hidden on leaves.
group
A nested role="group" list of child nodes; hidden when its parent is collapsed.
trailing
Per-row content after the label: one or more badge pills (a count plus a toned status, always shown) and hover-revealed actions buttons. Reachable at ::part(badge) and ::part(row-action).
Tokens & coverage
What the component consumes, checked live against what the algorithm produces.
Live coverage check against the xtyle-default register
(derive(xtyleDefault, { anchors }) →
coverComponent(manifest, register)). Every token this component
consumes must be a key the algorithm produces.
--accent-2-text
--accent-3-text
--accent-4-text
--accent-bg
--accent-text
--black-text
--blue-text
--border-normal
--border-thick
--brown-text
--cyan-text
--danger-text
--duration-fast
--ease-standard
--fg-0
--fg-1
--fg-2
--fg-3
--fg-disabled
--font-sans
--gray-text
--green-text
--info-text
--neutral-text
--orange-text
--pink-text
--purple-text
--radius-sm
--red-text
--ring
--selection-cue
--space-0
--space-1
--space-2
--space-4
--state-hover
--success-text
--text-body
--text-sm
--text-xs
--warn-text
--weight-medium
--white-text
--yellow-text
Accessibility
Code
A documentation tree
An expanded Guides branch with a selected page and links, beside a collapsed Reference branch.
<xtyle-tree label="Documentation"></xtyle-tree>
<script>
document.querySelector("xtyle-tree").items = [
{
label: "Guides",
expanded: true,
children: [
{ label: "Getting started", href: "/guides/start", selected: true },
{ label: "Theming", href: "/guides/theming" },
],
},
{
label: "Reference",
children: [{ label: "Engine", href: "/reference/engine" }],
},
];
</script>
<script lang="ts">
import { Tree } from "@xtyle/svelte";
const items = [
{
label: "Guides",
expanded: true,
children: [
{ label: "Getting started", href: "/guides/start", selected: true },
{ label: "Theming", href: "/guides/theming" },
],
},
{
label: "Reference",
children: [
{ label: "Engine", value: "engine", actions: [{ id: "rename", label: "Rename", icon: "✎" }] },
],
},
];
</script>
<Tree
label="Documentation"
{items}
onselect={(e) => console.log("select", e.detail.value)}
ontreeaction={(e) => console.log(e.detail.action, "on", e.detail.value)}
/>
---
import { Tree } from "@xtyle/astro";
const items = [
{
label: "Guides",
expanded: true,
children: [
{ label: "Getting started", href: "/guides/start", selected: true },
{ label: "Theming", href: "/guides/theming" },
],
},
{ label: "Reference", children: [{ label: "Engine", href: "/reference/engine" }] },
];
---
<Tree label="Documentation" items={items} />
Badges and row actions
A binder tree where each row carries a trailing badge (a count) and a hover cluster of action buttons, all firing tree-action.
<xtyle-tree label="Binder"></xtyle-tree>
<script>
const tree = document.querySelector("xtyle-tree");
tree.items = [
{
label: "Chapter 1", value: "ch1", expanded: true,
// a word count plus a toned drift pill: two trailing badges, each its own color
badge: ["1,204", { text: "3", tone: "warn" }],
// hover text for a label the rail will ellipse; label stays the accessible name
title: "The lighthouse keeper finds the letter",
children: [
{ label: "Opening", value: "ch1-1", badge: "512", title: "Dawn over the harbour", actions: [
{ id: "up", label: "Move up", icon: "↑", disabled: true },
{ id: "rename", label: "Rename", icon: "✎" },
{ id: "delete", label: "Delete", icon: "✕" },
] },
{ label: "Rising action", value: "ch1-2", actions: [{ id: "rename", label: "Rename", icon: "✎" }] },
],
},
{ label: "Chapter 2", value: "ch2", badge: "812", title: "She reads it twice, then burns it" },
];
// Badges show always; actions reveal on hover and fire tree-action (a disabled action stays greyed).
// title is supplementary hover text — a synopsis behind a truncated label.
tree.addEventListener("tree-action", (e) => console.log(e.detail.action, "on", e.detail.value));
</script>
Live data, kept state
A manuscript whose word counts stream in on every keystroke: items is re-set wholesale each time, and the user's expansion, selection, and tab stop ride through it untouched.
<xtyle-tree label="Manuscript"></xtyle-tree>
<script>
const tree = document.querySelector("xtyle-tree");
const counts = { "ch1-1": 512, "ch1-2": 692 };
// A live feed re-sets `items` wholesale on every edit — here, a per-row word count that ticks
// on every keystroke in the editor. Expansion, selection, and the roving tab stop are host-owned,
// so they ride straight through the swap: a branch the user collapsed stays collapsed even though
// the data still declares `expanded: true`, and the selected row stays selected.
const render = () => {
tree.items = [{
label: "Chapter 1", value: "ch1", expanded: true,
badge: String(counts["ch1-1"] + counts["ch1-2"]),
children: [
{ label: "Opening", value: "ch1-1", selected: true, badge: String(counts["ch1-1"]) },
{ label: "Rising action", value: "ch1-2", badge: String(counts["ch1-2"]) },
],
}];
};
editor.addEventListener("input", () => {
counts["ch1-1"] = editor.value.split(/\s+/).filter(Boolean).length;
render();
});
render();
// Only a *new* branch seeds from its own flags. To throw the user's state away on purpose
// (loading a different manuscript, say), ask for it:
tree.resetState();
</script>