registry:ui · Data display
tree-view — the extensible ARIA tree
A generic native-ARIA tree (nested ul role=tree / group / treeitem, roving tabindex, the full arrow-key contract) with an extension surface instead of baked-in opinions: prefix/suffix slots and onPrefixSlotRender / onSuffixSlotRender resolvers render any icon library or action row from live context, onactivate lets an extension own the Enter/Space behavior (the shipped multiselect extension does exactly that), and the built-in variants stop where they must — toggler glyph, guide lines, indent. The seven demos below mirror the reui.io tree reference one-for-one.
reference parity
The seven effects
The reui.io/components/tree reference ships seven effects; each one maps onto this architecture without forking it — plain rows, guide lines, custom indent with a prefix slot, the plus toggler, contextual type icons through the resolver, avatars through prefix + label snippets, and checkboxes through the multiselect extension.
1 · basic tree
Single-select CRM tree, chevron toggler, built-in file/folder icons (fileIcons). Folders toggle on click, leaves select — the file-tree lineage.
- Leads
- New Lead
- Contacted Lead
- Qualified Lead
- Accounts
- Acme Corp
- John Smith
- Jane Doe
- Website Redesign
- Globex Inc
- Alice Johnson
- Cloud Migration
- Activities
- Calls
- Meetings
- Emails
- Support
- Open Tickets
- Closed Tickets
<script lang="ts">
import TreeView, { type TreeNode } from '@ui/tree-view.svelte';
</script>
const leaf = (name: string): TreeNode => ({ name });
const crm: TreeNode[] = [
{ name: 'Leads', children: [leaf('New Lead'), leaf('Contacted Lead')] },
{ name: 'Accounts', children: [leaf('Acme Corp'), leaf('Globex Inc')] },
// …Activities, Support — same shape
];
let selected = $state('Leads/New Lead');
</script>
<TreeView
nodes={crm}
defaultExpanded={['Leads', 'Accounts']}
{selected}
fileIcons
onselect={(ctx) => (selected = ctx.id)}
/>2 · indented lines
The built-in lines variant: one 1px guide rail per indent level, drawn only as tall as each group.
- Leads
- New Lead
- Contacted Lead
- Qualified Lead
- Accounts
- Acme Corp
- John Smith
- Jane Doe
- Website Redesign
- Globex Inc
- Alice Johnson
- Cloud Migration
- Activities
- Calls
- Meetings
- Emails
- Support
- Open Tickets
- Closed Tickets
<TreeView nodes={crm} defaultExpanded={['Leads', 'Accounts', 'Activities']} lines />3 · custom indent · prefix slot
indent 32 plus the prefix snippet: folder glyphs swap with expand state, leaves get a file glyph — the static extension level.
- Leads
- New Lead
- Contacted Lead
- Qualified Lead
- Accounts
- Acme Corp
- John Smith
- Jane Doe
- Website Redesign
- Globex Inc
- Alice Johnson
- Cloud Migration
- Activities
- Calls
- Meetings
- Emails
- Support
- Open Tickets
- Closed Tickets
{#snippet crmPrefix(ctx)}
{#if ctx.isFolder}
<!-- the folder swaps glyphs with expand state; leaves show a file -->
<Icon name={ctx.expanded ? 'folderOpen' : 'folder'} />
{:else}
<Icon name="file" />
{/if}
{/snippet}
<TreeView
nodes={crm}
defaultExpanded={['Leads', 'Accounts', 'Activities']}
indent={32}
lines
prefix={crmPrefix}
/>4 · plus toggler
The plus toggler variant — expanded folders show −, collapsed show +.
- Leads
- New Lead
- Contacted Lead
- Qualified Lead
- Accounts
- Acme Corp
- John Smith
- Jane Doe
- Website Redesign
- Globex Inc
- Alice Johnson
- Cloud Migration
- Activities
- Calls
- Meetings
- Emails
- Support
- Open Tickets
- Closed Tickets
<TreeView
nodes={crm}
defaultExpanded={['Leads', 'Accounts', 'Activities']}
indent={32}
lines
toggle="plus"
prefix={crmPrefix}
/>5 · file explorer · onPrefixSlotRender
The dynamic extension level: a resolver returns a snippet per node context — amber folders, blue code, purple css, yellow json.
- my-project
- src
- app
- page.tsx
- layout.tsx
- loading.tsx
- components
- button.tsx
- card.tsx
- dialog.tsx
- lib
- utils.ts
- api.ts
- globals.css
- public
- favicon.ico
- package.json
- README.md
- tsconfig.json
<!-- one snippet per file type; the resolver RETURNS one per node -->
{#snippet folderIcon(ctx)}
<span class="text-amber-500"><Icon name={ctx.expanded ? 'folderOpen' : 'folder'} /></span>
{/snippet}
{#snippet tsIcon(ctx)}<span class="text-blue-500"><Icon name="fileCode" /></span>{/snippet}
{#snippet cssIcon(ctx)}<span class="text-purple-500"><Icon name="palette" /></span>{/snippet}
{#snippet jsonIcon(ctx)}<span class="text-yellow-500"><Icon name="braces" /></span>{/snippet}
{#snippet mdIcon(ctx)}<span class="text-muted-foreground"><Icon name="fileText" /></span>{/snippet}
{#snippet genericIcon(ctx)}<span class="text-muted-foreground"><Icon name="file" /></span>{/snippet}
<TreeView
nodes={fileTree}
defaultExpanded={['my-project', 'my-project/src', 'my-project/src/app']}
onPrefixSlotRender={(ctx) => {
if (ctx.isFolder) return folderIcon;
switch (ctx.node.meta) {
case 'ts':
case 'tsx': return tsIcon;
case 'css': return cssIcon;
case 'json': return jsonIcon;
case 'md': return mdIcon;
default: return genericIcon;
}
}}
/>6 · org chart · prefix + label
Avatars through the prefix snippet and a two-line label snippet — rows grow with their content.
- Acme Inc.Acme Inc. Company
- Sarah ChenSarah Chen CEO
- Alex JohnsonAlex Johnson CTO
- Michael RodriguezMichael Rodriguez Engineering Lead
- Tom HarrisTom Harris Senior Developer
- Nina PatelNina Patel Developer
- Lisa ParkLisa Park Design Lead
- Emma WilsonEmma Wilson COO
- James BrownJames Brown Operations Manager
- Amy TaylorAmy Taylor HR Manager
- David KimDavid Kim CFO
- Robert DavisRobert Davis Finance Manager
{#snippet orgPrefix(ctx)}
<Avatar name={ctx.node.name} src={ctx.node.meta?.avatar} size="sm" alt="" />
{/snippet}
{#snippet orgLabel(ctx)}
<span class="flex flex-col items-start py-0.5 leading-tight">
<span class="text-foreground text-[12px]">{ctx.node.name}</span>
{#if ctx.node.meta?.role}
<span class="text-muted-foreground text-[10px]">{ctx.node.meta.role}</span>
{/if}
</span>
{/snippet}
<TreeView
nodes={org}
defaultExpanded={['Acme Inc.', 'Acme Inc./Sarah Chen']}
indent={24}
prefix={orgPrefix}
label={orgLabel}
/>7 · permissions · multiselect
The shipped tree-view-multiselect extension: tri-state cascade, Space toggles the focused row, arrows keep walking.
- User Management
- View users
- Create users
- Edit users
- Delete users
- Content Management
- View content
- Publish content
- Delete content
- Billing
- View invoices
- Manage subscriptions
- API Access
- Read access
- Write access
<script lang="ts">
import TreeViewMulti from '@ui/tree-view-multiselect.svelte';
</script>
let checked = $state([
'User Management/View users',
'Content Management/View content',
// …
]);
</script>
<TreeViewMulti
nodes={permissions}
defaultExpanded={['User Management', 'Content Management']}
bind:checked
oncheck={(ids) => console.log(ids)}
/>tree-view · suffix actions + disabled
onSuffixSlotRender arms only the folders with Add / More icon buttons (revealed on row hover or focus), and the archive subtree ships disabled: focusable for screen readers, never activatable.
- src
- lib
- icons.ts
- site.ts
- jixoai.css
- routes
- tokens.html
- recipes.html
- archive
- old-site.svelte
- legacy-theme.css
- package.json
{#snippet folderActions(ctx)}
<button class="jx-demo-act" title="Add" onclick={() => addInside(ctx.id)}>
<Icon name="plus" />
</button>
<button class="jx-demo-act" title="More" onclick={() => moreFor(ctx.id)}>
<Icon name="ellipsis" />
</button>
{/snippet}
<TreeView
nodes={projectTree}
defaultExpanded={['src', 'src/lib', 'src/routes']}
onSuffixSlotRender={((ctx: TreeItemCtx) => (ctx.isFolder ? folderActions : undefined)) as unknown as TreeSlotRender}
/>law
The tree keyboard contract
The ARIA tree pattern is a keyboard contract before it is a widget: ↑/↓ move focus between visible items, → expands a collapsed directory (or jumps into its first child), ← collapses an expanded one (or returns to the parent), Home / End jump the ends, Enter / Space activates the item. Roving tabindex keeps exactly one tab stop; collapsed extents are inert, so hidden rows are untabbable by construction. Extensions hook the activation through onactivate and ctx.preventDefault() — the multiselect turns Space into a checkbox toggle without touching the walker.
<script lang="ts">
import TreeView, { type TreeNode } from '@ui/tree-view.svelte';
</script>
const leaf = (name: string): TreeNode => ({ name });
const crm: TreeNode[] = [
{ name: 'Leads', children: [leaf('New Lead'), leaf('Contacted Lead')] },
{ name: 'Accounts', children: [leaf('Acme Corp'), leaf('Globex Inc')] },
// …Activities, Support — same shape
];
let selected = $state('Leads/New Lead');
</script>
<TreeView
nodes={crm}
defaultExpanded={['Leads', 'Accounts']}
{selected}
fileIcons
onselect={(ctx) => (selected = ctx.id)}
/>types
Tree-view variants
The built-ins stop where they must — toggler glyph, guide lines, indent — and the shipped multiselect extension adds tri-state cascade.
tree-view · variants
- src
- icons.ts
- site.ts
- docs
- tokens.html
- package.json
- src
- icons.ts
- site.ts
- docs
- tokens.html
- package.json
- src
- icons.ts
- site.ts
- docs
- tokens.html
- package.json
<script lang="ts">
import TreeView, { type TreeNode } from '@ui/tree-view.svelte';
import TreeViewMulti from '@ui/tree-view-multiselect.svelte';
</script>
const leaf = (name: string): TreeNode => ({ name });
const miniTree: TreeNode[] = [
{ name: 'src', children: [leaf('icons.ts'), leaf('site.ts')] },
{ name: 'docs', children: [leaf('tokens.html')] },
leaf('package.json'),
];
let miniSelected = $state('src/icons.ts');
let miniChecked = $state<string[]>(['docs/tokens.html']);
<div class="grid w-full items-start gap-4 min-[900px]:grid-cols-3">
<div class="border border-border p-3"><TreeView nodes={miniTree} defaultExpanded={['src']} selected={miniSelected} fileIcons onselect={(ctx) => (miniSelected = ctx.id)} /></div>
<div class="border border-border p-3"><TreeView nodes={miniTree} defaultExpanded={['src', 'docs']} toggle="plus" lines indent={24} /></div>
<div class="border border-border p-3"><TreeViewMulti nodes={miniTree} defaultExpanded={['src', 'docs']} bind:checked={miniChecked} /></div>
</div>usage
Usage
Nodes are plain data (name/children/disabled/meta); selection stays consumer-owned through the controlled selected prop.
<script lang="ts">
import TreeView, { type TreeNode } from '@ui/tree-view.svelte';
</script>
const leaf = (name: string): TreeNode => ({ name });
const crm: TreeNode[] = [
{ name: 'Leads', children: [leaf('New Lead'), leaf('Contacted Lead')] },
{ name: 'Accounts', children: [leaf('Acme Corp'), leaf('Globex Inc')] },
// …Activities, Support — same shape
];
let selected = $state('Leads/New Lead');
</script>
<TreeView
nodes={crm}
defaultExpanded={['Leads', 'Accounts']}
{selected}
fileIcons
onselect={(ctx) => (selected = ctx.id)}
/>a11y
Accessibility
A native-ARIA tree: nested tree/group/treeitem roles, roving tabindex, and the full APG arrow contract.
Keyboard
| Key | Action |
|---|---|
| ↑ / ↓ | Move focus between visible items (roving tabindex) |
| → | Expand a collapsed folder, or jump into its first child |
| ← | Collapse an expanded folder, or return to the parent |
| Home / End | Jump to the first / last visible item |
| Enter / Space | Activate the item — folders toggle, leaves select; extensions may preventDefault |
ARIA
| Attribute | Value | Description |
|---|---|---|
| role | tree / group / treeitem | Nested native-ARIA tree roles; one tab stop by roving tabindex. |
| aria-expanded | folders only | Reports the folder’s collapsed state. |
| aria-selected | leaves only | Mirrors the controlled selected path id. |
| aria-disabled | disabled nodes | Focusable for screen readers, never activatable (APG disabled treeitem). |
| aria-label | ariaLabel prop | Names the tree (default "tree"). |
theming
Density and tokens
Row paint rides the ambient chain (measured: row height drifts 17.5/18/18.5/19.2px across the rungs — the density response flows; no fixed utility pins it); --jx-indent is the one geometry lever — it drives group padding and the lines guide rails together.
- src
- icons.ts
- site.ts
- docs
- tokens.html
- package.json
- src
- icons.ts
- site.ts
- docs
- tokens.html
- package.json
- src
- icons.ts
- site.ts
- docs
- tokens.html
- package.json
- src
- icons.ts
- site.ts
- docs
- tokens.html
- package.json
| Token | Default | Source |
|---|---|---|
| --jx-indent | 16px (indent prop) | component |
| guide rails | 1px var(--border) | color |
| focus ring | 2px var(--ring) | color |
| multiselect box | var(--primary) fill | color |
| type icons | accent 60% mix | color |
| suffix reveal | hover / focus-within | structural |
axes
Universal props
The eight-axis surface (explicit-props): size · shape · radius · density · color · theme · elevation · motion — each axis takes named steps, auto (inherit the ambient context; stamps nothing), an exact number (px · coefficient · dp · hue per axis), or query() for responsive/container-conditional values. The ul[role=tree] is the family's own DOM root (a FIRST-TIME no-own contract) — a dense file tree at size 12 is the use case; the multiselect sibling forwards the lanes to the composed root, and the rows/carets/language dots ride the ambient chain.
Tree view · universal props
- Leads
- New Lead
- Contacted Lead
- Qualified Lead
- Accounts
- Acme Corp
- John Smith
- Jane Doe
- Website Redesign
- Globex Inc
- Alice Johnson
- Cloud Migration
- Activities
- Calls
- Meetings
- Emails
- Support
- Open Tickets
- Closed Tickets
- Leads
- New Lead
- Contacted Lead
- Qualified Lead
- Accounts
- Acme Corp
- John Smith
- Jane Doe
- Website Redesign
- Globex Inc
- Alice Johnson
- Cloud Migration
- Activities
- Calls
- Meetings
- Emails
- Support
- Open Tickets
- Closed Tickets
<TreeView nodes={tree} size={12} density="small" />api
API
Slots and resolvers receive TreeItemCtx (node, id, depth, isFolder, expanded, selected, disabled, preventDefault); the multiselect extension reuses the same seams.
Properties
| Property | Type | Default | Description |
|---|---|---|---|
| nodes * | TreeNode<T>[] | — | The tree data — name, children, disabled, consumer meta. |
| defaultExpanded | string[] | [] | Folder ids expanded on mount; uncontrolled afterwards. |
| selected | string | — | Single-selection path id (consumer-owned; leaves select). |
| prefix | Snippet<[TreeItemCtx<T>]> | — | Prefix column rendered for every node with the item ctx. |
| suffix | Snippet<[TreeItemCtx<T>]> | — | Suffix column, hidden until row hover/focus-within (actions law). |
| onPrefixSlotRender | TreeSlotRender<T> | — | Dynamic prefix: pick a snippet per node; wins over prefix. |
| onSuffixSlotRender | TreeSlotRender<T> | — | Dynamic suffix: pick a snippet per node; wins over suffix. |
| label | Snippet<[TreeItemCtx<T>]> | node.name | Label override. |
| onactivate | (ctx: TreeItemCtx<T>) => void | — | Click / Enter / Space — runs first; ctx.preventDefault() cancels default. |
| ontoggle | (ctx: TreeItemCtx<T>) => void | — | Folder expand/collapse; ctx.expanded is the NEW state. |
| onselect | (ctx: TreeItemCtx<T>) => void | — | Leaf selection; the controlled selected prop stays the source of truth. |
| toggle | 'chevron' | 'plus' | 'chevron' | Toggler glyph variant (built-in). |
| fileIcons | boolean | false | Built-in file-tree icons in the prefix column; consumer snippets win per node. |
| lines | boolean | false | Vertical guide rails per indent level (built-in). |
| indent | number | 16 | px per level; feeds --jx-indent. |
| ariaLabel | string | 'tree' | Names the tree. |
| class | string | '' | Extra classes on the root. |
Universal props
| Property | Type | Default | Description |
|---|---|---|---|
| size | 'small' | 'medium' | 'large' | 'auto' | number | 'auto' · ambient scope | the base scale — root font-size; parts size in em |
| shape | 'round' | 'scoop' | 'bevel' | 'notch' | 'square' | 'squircle' | 'auto' | 'auto' · ambient scope | corner geometry (CSS corner-shape; §14 degrade table) |
| radius | 'small' | 'medium' | 'large' | 'auto' | number | 'auto' · ambient scope | corner size; auto = the concentric broadcast (§3) |
| density | 'small' | 'medium' | 'large' | 'auto' | number | 'auto' · ambient scope | spacing/leading scale over the kernel channels (§4) |
| color | 'primary' | 'secondary' | 'error' | 'warn' | 'success' | 'info' | 'auto' | number | string | 'auto' · ambient scope | the hue axis of the fixed oklch primary system (§5) |
| theme | 'light' | 'dark' | 'system' | 'auto' | 'auto' · ambient scope | light/dark profile; system = the JS-mutable global (§6) |
| elevation | 'level-1' | 'level0' | 'level1' | 'level2' | 'level3' | 'level4' | 'level5' | 'auto' | number | 'auto' · ambient scope | official M3 levels over the surface ladder (§7) |
| motion | 'reduced' | 'subtle' | 'normal' | 'expressive' | 'auto' | number | 'auto' · ambient scope | intensity across the motion kernels (§8) |
TreeNode
| Property | Type | Default | Description |
|---|---|---|---|
| name * | string | — | Row label source and path segment. |
| children | TreeNode<T>[] | — | Presence makes the node a folder. |
| disabled | boolean | — | Focusable but not activatable; freezes the subtree. |
| meta | T | — | Consumer payload — flows untouched into ctx.node.meta. |
TreeViewMulti — extension additions
| Property | Type | Default | Description |
|---|---|---|---|
checked bind | string[] | [...defaultChecked] | Checked path ids (folders included when fully on). |
| defaultChecked | string[] | [] | Initial ids when the consumer does not bind checked. |
| oncheck | (ids: string[], ctx: TreeItemCtx<T>) => void | — | After every commit; ctx is the row that caused it. |