Primitives Navigation
Tree
A hierarchy of items that open and close, with one tab stop and the keyboard of the WAI-ARIA tree view. Expansion lives in aria-expanded, selection in aria-selected and data-slean-value, focus in the roving tabindex: the behavior keeps no memory. Tier 1.
- Tier
- 1 · Delegated micro-behavior
- Behavior JS
- 1919 B brotli · 2139 B gzip ·
packages/· methodprimitives/ artifacts/ size.json - Platform features
role="tree",treeitem,group,aria-expanded,aria-selected,:dir(rtl)- Shared listeners
- click, keydown
- Per-instance listeners
- none
- Lazy state
- none
- Native base
role="tree", treeitem, group, aria-expanded
On this page
Example
- src
- primitives
- button.ts
- tabs.ts
- tree.ts
- styles
- tokens.css
- base.css
- index.ts
- primitives
- docs
- catalog.md
- README.md
data-slean-value = button
<ul role="tree" aria-label="Project files" data-slean="tree" data-slean-value="button">
<li role="treeitem" aria-expanded="true" aria-selected="false" data-slean-value="src" tabindex="-1">
<span data-slean-part="label">src</span>
<ul role="group">
<li role="treeitem" aria-expanded="true" aria-selected="false" data-slean-value="primitives" tabindex="-1">
<span data-slean-part="label">primitives</span>
<ul role="group">
<li role="treeitem" aria-selected="true" data-slean-value="button" tabindex="0">
<span data-slean-part="label">button.ts</span>
</li>
<li role="treeitem" aria-selected="false" data-slean-value="tabs" tabindex="-1">
<span data-slean-part="label">tabs.ts</span>
</li>
</ul>
</li>
<li role="treeitem" aria-expanded="false" aria-selected="false" data-slean-value="styles" tabindex="-1">
<span data-slean-part="label">styles</span>
<ul role="group">…</ul>
</li>
</ul>
</li>
<li role="treeitem" aria-selected="false" data-slean-value="readme" tabindex="-1">
<span data-slean-part="label">README.md</span>
</li>
</ul><script lang="ts">
// With @svelte-lean/vite the register import is injected for the static data-slean="tree".
import '@svelte-lean/styles/tree.css';
import type { TreeChangeDetail } from '@svelte-lean/primitives/tree';
import { on } from 'svelte/events';
interface Node {
value: string;
label: string;
open?: boolean;
children?: Node[];
}
let { nodes, selected = $bindable('') }: { nodes: Node[]; selected?: string } = $props();
// The first item is the tab stop until the behavior moves it.
const first = nodes[0]?.value;
</script>
{#snippet items(list: Node[])}
{#each list as node (node.value)}
<li
role="treeitem"
data-slean-value={node.value}
aria-expanded={node.children ? (node.open ?? false) : undefined}
aria-selected={node.value === selected}
tabindex={node.value === (selected || first) ? 0 : -1}
>
<span data-slean-part="label">{node.label}</span>
{#if node.children}<ul role="group">{@render items(node.children)}</ul>{/if}
</li>
{/each}
{/snippet}
<ul
role="tree"
aria-label="Project files"
data-slean="tree"
data-slean-value={selected}
{@attach (node) =>
on(node, 'slean:change', (event) => {
selected = (event as CustomEvent<TreeChangeDetail>).detail.value;
})}
>
{@render items(nodes)}
</ul>Why this implementation exists
The platform’s disclosure (<details>) opens and closes a section, but a tree is more: the whole hierarchy is one tab stop, the arrows move through the visible items, Right and Left open, close and move between levels, and typing jumps to an item. None of that is native.
All of it can live in the DOM. aria-expanded is the expansion and the stylesheet hides a collapsed group, aria-selected and the root’s data-slean-value are the selection, and one item holds tabindex="0". The behavior computes the visible items from the attributes when a key is pressed and keeps nothing in between.
The browser owns
- nested lists and their semantics; the level, set size and position derived from the nesting
- focus on the item and the click and keydown events routed to the behavior
Svelte Lean owns
- the WAI-ARIA tree view keyboard: arrows, Home, End, *, typeahead, RTL
- expansion in aria-expanded, selection in aria-selected and data-slean-value
- the roving tabindex, and focus moving to a parent that hides it
- the cancelable slean:change and slean:toggle events, development validation
- tree.css: indentation with a guide line, chevrons, hidden collapsed groups, the focus ring on the label
Usage
Install @svelte-lean/primitives for the behavior and @svelte-lean/styles for the stylesheet. With @svelte-lean/vite the
registration is injected for every static data-slean="tree"; without it, import
the register module once.
npm install @svelte-lean/primitivespnpm add @svelte-lean/primitivesyarn add @svelte-lean/primitivesbun add @svelte-lean/primitivesimport '@svelte-lean/primitives/tree/register';import '@svelte-lean/styles/tree.css';Render nested lists: role="tree" on the root, role="treeitem" on each item with a label part as its first child, aria-expanded and a role="group" list on parents. Give exactly one item tabindex="0".
Listen to slean:change for the selected value and slean:toggle for expansion. Set data-slean-selection="none" for a navigation tree whose labels are links.
Anatomy
| Part | Element | data-slean-part | Required | Notes |
|---|---|---|---|---|
| root | <ul role="tree" aria-label="…" data-slean="tree"> | – | yes | Options: data-slean-value (the selected item), data-slean-selection (single, none). |
| item | <li role="treeitem" tabindex="0|-1" data-slean-value="…"> | – | yes | Found by role. A parent carries aria-expanded and a role="group" list. |
| label | <span> | label | yes | The item’s first child: its text, the focus ring and the selection. |
| group | <ul role="group"> | – | no | The children of a parent; hidden while it is collapsed. |
Runtime profile
The tree registers one click and one keydown handler with the shared router. A thousand trees keep one listener per type (tests/tree.test.ts).
Accessibility contract
role="tree"named byaria-label;treeitem,group,aria-expandedandaria-selected. Assistive technology derives the level and the position from the nesting.- One tab stop; the arrows, Home, End,
*and typeahead move focus among the visible items. - Closing a parent that holds focus moves focus to the parent, so focus never disappears into a hidden group.
- The focus ring and the selection are drawn on the label, not around the whole subtree.
Keyboard
| Key | When | Result |
|---|---|---|
| ArrowDown/ArrowUp | focus on an item | Next or previous visible item |
| ArrowRight | focus on a parent | Opens it; when open, moves to the first child |
| ArrowLeft | focus on an item | Closes an open parent; otherwise moves to the parent |
| Home/End | focus on an item | First or last visible item |
| Enter | focus on an item | Selects it and opens or closes a parent |
| Space | focus on an item | Selects it |
| * | focus on an item | Opens every sibling |
| a–z | focus on an item | Next visible item starting with the typed text |
Platform features
| Feature | Baseline | Outside the target |
|---|---|---|
ARIA tree roles | ARIA 1.2, widely supported by assistive technology | Not applicable |
:dir(rtl) | Baseline 2023 (Chrome 120, Safari 16.4, Firefox 49) | The chevrons do not mirror; dir="auto" resolves left to right |
Without JavaScript
The tree renders as nested lists with the server's expansion. Collapsed groups stay hidden and cannot be opened; links in labels still work. Where a hierarchy must be browsable without JavaScript, use nested disclosures.
Server rendering
Render aria-expanded, aria-selected and the roving tabindex on the server. The behavior writes nothing until the first interaction.
Before hydration
Before the behavior loads, clicks do nothing and Tab reaches the one item with tabindex 0. The first event after hydration works on the server's markup.
Styling
tree.css indents children under a guide line, draws the chevron of a parent with two borders (turned by aria-expanded, mirrored under :dir(rtl)), hides collapsed groups and puts the focus ring and the selection on the label. The tokens it reads:
| Token | Default (light) | Applies to |
|---|---|---|
--slean-fg | var(--slean-neutral-12) | color |
--slean-text-sm | 0.875rem | font-size |
--slean-space-2 | 0.5rem | margin-inline-start, gap, padding-inline, padding-inline-start |
--slean-space-1 | 0.25rem | padding-inline-start, padding-block |
--slean-border | var(--slean-neutral-6) | border-inline-start |
--slean-control-height-sm | 2rem | min-block-size |
--slean-radius-sm | 0.375rem | border-radius |
--slean-fg-muted | var(--slean-neutral-11) | background |
--slean-icon-chevron-right | url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M6 4l4 4-4 4' stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round' fill='none'/%3E%3C/svg%3E") | mask |
--slean-duration-fast | 100ms | transition |
--slean-ease | cubic-bezier(0.2, 0, 0, 1) | transition |
--slean-muted | var(--slean-neutral-3) | background |
--slean-option-active | var(--slean-muted) | background |
--slean-focus-ring | var(--slean-focus-ring-width) solid var(--slean-focus-ring-color) | outline |
--slean-focus-ring-width | 2px | outline-offset |
--slean-accent-soft | oklch(95% 0.03 258) | background |
--slean-accent-soft-fg | oklch(42% 0.17 258) | color, box-shadow |
--slean-control-height-md | 2.25rem | min-block-size |
State selectors the stylesheet targets, all from the platform or ARIA: :dir(rtl), :focus-visible, :hover, [aria-disabled="true"], [aria-expanded="false"], [aria-expanded="true"], [aria-expanded], [aria-selected="true"], [role="group"], [role="treeitem"].
Compatibility notes
Nothing beyond lists and ARIA tree roles. Multiple selection, checkboxes and drag and drop are not part of the primitive.
Examples
Children loaded on demand
slean:toggle is dispatched before a parent opens or closes, with the item’s value and
the new state. A listener that fills the group the first time a parent opens loads a large hierarchy
one level at a time; cancelling the event keeps the item as it is.
<script lang="ts">
import type { TreeToggleDetail } from '@svelte-lean/primitives/tree';
import { on } from 'svelte/events';
// Children per parent value, filled the first time a parent opens.
let children = $state<Record<string, { value: string; label: string }[]>>({});
function toggle(event: Event) {
const { value, expanded } = (event as CustomEvent<TreeToggleDetail>).detail;
if (expanded && !children[value]) {
children[value] = []; // an empty group while the request runs
fetch(`/api/folders/${value}`)
.then((response) => response.json())
.then((list) => (children[value] = list));
}
}
</script>
<ul role="tree" aria-label="Folders" data-slean="tree" {@attach (node) => on(node, 'slean:toggle', toggle)}>
…
</ul>Testing
packages/primitives/tests/tree.test.tsVitest: keyboard, RTL, typeahead, clicks, cancelable events, nested trees, 1000 rootsapps/playground/tests/primitives/widgets.spec.tsPlaywright: tooltip, toast, tree, range slider and number field in Chrome, with axepackages/styles/testsstatic checks of the stylesheet (layers, tokens, specificity, dark parity)
Source
packages/primitives/src/tree/contract.mdthe contract in prose: semantics, keyboard, focus, ARIA, SSR, JS-disabled behaviorpackages/primitives/src/tree/contract.tstyped constants: name, tier, base, parts, options, eventspackages/primitives/src/tree/behavior.tsthe behavior definitionpackages/primitives/src/tree/register.tsthe registration modulepackages/styles/css/tree.cssthe optional stylesheet