# Tree

> role="tree" with nested groups: expansion and roving focus through shared listeners, state in aria-expanded and tabindex.

Page: https://svelte-lean.vercel.app/primitives/tree · Markdown: https://svelte-lean.vercel.app/primitives/tree.md · Index: https://svelte-lean.vercel.app/llms.txt

A hierarchy of items that open and close with one tab stop and the WAI-ARIA tree view keyboard, for file browsers and nested navigation. Tier 1.

```svelte
<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';

	type Node = { value: string; label: string; open?: boolean; children?: Node[] };

	const nodes: Node[] = [
		{
			value: 'src',
			label: 'src',
			open: true,
			children: [
				{
					value: 'primitives',
					label: 'primitives',
					open: true,
					children: [
						{ value: 'button', label: 'button.ts' },
						{ value: 'tabs', label: 'tabs.ts' },
						{ value: 'tree', label: 'tree.ts' }
					]
				},
				{
					value: 'styles',
					label: 'styles',
					children: [
						{ value: 'tokens', label: 'tokens.css' },
						{ value: 'base', label: 'base.css' }
					]
				},
				{ value: 'index', label: 'index.ts' }
			]
		},
		{ value: 'docs', label: 'docs', children: [{ value: 'catalog', label: 'catalog.md' }] },
		{ value: 'readme', label: 'README.md' }
	];

	let selected = $state('button');
	const follow = (root: HTMLElement) =>
		on(root, 'slean:change', (event) => {
			selected = (event as CustomEvent<TreeChangeDetail>).detail.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 === 'button' ? 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="button" {@attach follow}>
	{@render items(nodes)}
</ul>
<p>Selected: {selected}</p>
```

Arrows move, Right and Left open and close, \* opens siblings, and typing a letter jumps.

## Examples

### Children loaded on demand

```svelte
<script lang="ts">
	import type { TreeToggleDetail } from '@svelte-lean/primitives/tree';
	import { on } from 'svelte/events';

	const folders = [
		{ value: 'photos', label: 'Photos' },
		{ value: 'music', label: 'Music' },
		{ value: 'documents', label: 'Documents' }
	];
	// Children per folder, filled the first time the folder opens.
	let children = $state<Record<string, string[] | undefined>>({});

	// slean:toggle is dispatched before a parent opens or closes; cancelling it keeps the item.
	const load = (root: HTMLElement) =>
		on(root, 'slean:toggle', async (event) => {
			const { value, expanded } = (event as CustomEvent<TreeToggleDetail>).detail;
			if (!expanded || children[value]) return;
			children[value] = [];
			const response = await fetch(`/api/folders/${value}`);
			children[value] = await response.json();
		});
</script>

<ul role="tree" aria-label="Folders" data-slean="tree" {@attach load}>
	{#each folders as folder, i (folder.value)}
		<li role="treeitem" aria-expanded="false" aria-selected="false"
			data-slean-value={folder.value} tabindex={i === 0 ? 0 : -1}>
			<span data-slean-part="label">{folder.label}</span>
			<ul role="group">
				{#if children[folder.value]?.length === 0}
					<li role="treeitem" aria-selected="false" aria-disabled="true" tabindex="-1">
						<span data-slean-part="label">Loading…</span>
					</li>
				{/if}
				{#each children[folder.value] ?? [] as name (name)}
					<li role="treeitem" aria-selected="false" data-slean-value="{folder.value}/{name}"
						tabindex="-1">
						<span data-slean-part="label">{name}</span>
					</li>
				{/each}
			</ul>
		</li>
	{/each}
</ul>
```

slean:toggle fires before a folder opens; the listener fills its group the first time.

### Disabled items

```svelte
<ul role="tree" aria-label="Permissions" data-slean="tree" data-slean-value="members">
	<li role="treeitem" aria-expanded="true" aria-selected="false" data-slean-value="workspace"
		tabindex="-1">
		<span data-slean-part="label">Workspace</span>
		<ul role="group">
			<li role="treeitem" aria-selected="true" data-slean-value="members" tabindex="0">
				<span data-slean-part="label">Members</span>
			</li>
			<li role="treeitem" aria-selected="false" aria-disabled="true" data-slean-value="billing"
				tabindex="-1">
				<span data-slean-part="label">Billing (owners only)</span>
			</li>
			<li role="treeitem" aria-expanded="false" aria-selected="false" aria-disabled="true"
				data-slean-value="audit" tabindex="-1">
				<span data-slean-part="label">Audit log (owners only)</span>
				<ul role="group">
					<li role="treeitem" aria-selected="false" data-slean-value="exports" tabindex="-1">
						<span data-slean-part="label">Exports</span>
					</li>
				</ul>
			</li>
		</ul>
	</li>
</ul>
```

An aria-disabled item can be focused and opened but not selected.

## Usage

Install `@svelte-lean/primitives` and `@svelte-lean/styles`. With `@svelte-lean/vite` the behavior registers itself for every `data-slean="tree"`; without it, import the register module once.

```bash
npm install @svelte-lean/primitives
```

`+layout.svelte or any client module`

```ts
import '@svelte-lean/primitives/tree/register';
```

`stylesheets`

```ts
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. |

## 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 |

## Styling

`tree.css` indents children under a guide line, draws the chevron of a parent as a masked icon (turned by `aria-expanded`, mirrored under `:dir(rtl)`), hides collapsed groups and puts the focus ring and the selection on the label. `data-color` on the root or an ancestor tints the selection (primary by default). 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` | `calc(var(--slean-spacing) * 2)` | margin-inline-start, gap, padding-inline, padding-inline-start |
| `--slean-space-1` | `calc(var(--slean-spacing) * 1)` | 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` | `calc(var(--slean-radius) * 0.6)` | 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-color-soft` | `var(--slean-primary-soft)` | background |
| `--slean-color-soft-text` | `var(--slean-primary-soft-text)` | 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"]`.

## Under the hood

Runtime and size

- **Tier**: 1 · Delegated micro-behavior
- **Behavior JS**: 2140 B brotli · 2384 B gzip · `packages/primitives/artifacts/size.json` · [method](https://svelte-lean.vercel.app/performance/methodology.md)
- **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`

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`).

Why this implementation

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, so the behavior keeps no memory. `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

Accessibility

- `role="tree"` named by `aria-label`; `treeitem`, `group`, `aria-expanded` and `aria-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.

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 and hydration

Render `aria-expanded`, `aria-selected` and the roving tabindex on the server. The behavior writes nothing until the first interaction.

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.

Compatibility

Nothing beyond lists and ARIA tree roles. Multiple selection, checkboxes and drag and drop are not part of the primitive.

Tests and source

- `packages/primitives/tests/tree.test.ts` Vitest: keyboard, RTL, typeahead, clicks, cancelable events, nested trees, 1000 roots
- `apps/playground/tests/primitives/widgets.spec.ts` Playwright: the tooltip, toast, tree, range slider, number field, file drop and hover card, with axe
- `packages/styles/tests` static checks of the stylesheet (layers, tokens, specificity, dark parity)

- `packages/primitives/src/tree/contract.md` the contract in prose: semantics, keyboard, focus, ARIA, SSR, JS-disabled behavior
- `packages/primitives/src/tree/contract.ts` typed constants: name, tier, base, parts, options, events
- `packages/primitives/src/tree/behavior.ts` the behavior definition
- `packages/primitives/src/tree/register.ts` the registration module
- `packages/styles/css/tree.css` the optional stylesheet
