Tree view
A hierarchy the keyboard can walk - the WAI-ARIA tree pattern, from a plain array.
pnpm dlx shadcn-svelte@latest add https://coral.imlargo.dev/r/kit-tree-view.jsonnpx shadcn-svelte@latest add https://coral.imlargo.dev/r/kit-tree-view.jsonbunx --bun shadcn-svelte@latest add https://coral.imlargo.dev/r/kit-tree-view.jsonyarn dlx shadcn-svelte@latest add https://coral.imlargo.dev/r/kit-tree-view.jsonNested <ul>s with a chevron that toggles a boolean is a tree for the mouse. The keyboard gets
nothing, or a tab stop per row, which in a folder of two hundred files is two hundred Tab presses to
get past it. A screen reader hears a list of lists with no idea what is open.
Selected: —
<script lang="ts">
import FileIcon from '@lucide/svelte/icons/file';
import FolderIcon from '@lucide/svelte/icons/folder';
import FolderOpenIcon from '@lucide/svelte/icons/folder-open';
import TreeView from '#lib/components/coral/kit/tree-view/tree-view.svelte';
import type { TreeNode } from '#lib/components/coral/kit/tree-view/types.js';
const nodes: TreeNode[] = [
{
id: 'src',
label: 'src',
children: [
{
id: 'src-routes',
label: 'routes',
children: [
{ id: 'src-routes-page', label: '+page.svelte' },
{ id: 'src-routes-layout', label: '+layout.svelte' }
]
},
{
id: 'src-lib',
label: 'lib',
children: [{ id: 'src-lib-utils', label: 'utils.ts' }]
}
]
},
{
id: 'tests',
label: 'tests',
children: [
{ id: 'tests-utils', label: 'utils.test.ts' },
{ id: 'tests-e2e', label: 'smoke.test.ts', disabled: true }
]
},
{ id: 'readme', label: 'README.md' }
];
let expanded = $state(['src']);
let selected = $state<string>();
</script>
<div class="flex w-72 flex-col gap-3">
<TreeView
{nodes}
bind:expanded
bind:selected
label="Source tree"
rowClass="rounded-md px-1 py-0.5 data-selected:bg-muted"
>
{#snippet node({ node, expandable, expanded })}
{#if expandable}
{#if expanded}<FolderOpenIcon class="size-4 shrink-0" />{:else}<FolderIcon
class="size-4 shrink-0"
/>{/if}
{:else}
<FileIcon class="size-4 shrink-0" />
{/if}
<span class="min-w-0 truncate text-sm">{node.label}</span>
{/snippet}
</TreeView>
<p class="text-sm text-muted-foreground">Selected: {selected ?? '—'}</p>
</div>What Coral adds
- One tab stop: the tree is reached with Tab once; inside it, the arrows move.
- The whole keyboard pattern: Up and Down move; Right opens a node, then steps into it; Left closes
it, then steps out to the parent; Home and End; Enter and Space select;
*opens every sibling; typing letters jumps to the next matching row, ignoring case and accents. - Announced structure:
aria-level,aria-posinset,aria-setsizeandaria-expandedon every row: "src, expanded, level 1, 1 of 3". - Focus never lost. Collapsing a folder that contains the focused row moves focus to the folder.
- Disabled nodes stay reachable. They cannot be selected, but can still be read and expanded.
- Right-to-left aware. Left and Right swap, and the chevron flips.
Loading children on demand
<script lang="ts">
import { SvelteSet } from 'svelte/reactivity';
import LoaderCircleIcon from '@lucide/svelte/icons/loader-circle';
import TreeView from '#lib/components/coral/kit/tree-view/tree-view.svelte';
import type { TreeNode } from '#lib/components/coral/kit/tree-view/types.js';
let nodes = $state<TreeNode[]>([
{ id: 'web-app', label: 'web-app', children: [] },
{ id: 'api-gateway', label: 'api-gateway', children: [] },
{ id: 'design-tokens', label: 'design-tokens', children: [] }
]);
const loaded = new SvelteSet<string>();
let loading = $state<string[]>([]);
// Fetches a repository's branches the first time it is opened.
async function load(expanded: string[]) {
for (const id of expanded) {
if (loaded.has(id)) continue;
loaded.add(id);
loading = [...loading, id];
await new Promise((resolve) => setTimeout(resolve, 700));
nodes = nodes.map((node) =>
node.id === id
? {
...node,
children: ['main', 'develop', 'release/2.1'].map((name) => ({
id: `${id}-${name}`,
label: name
}))
}
: node
);
loading = loading.filter((entry) => entry !== id);
}
}
</script>
<TreeView {nodes} label="Repositories" class="w-72" onexpandedchange={load}>
{#snippet node({ node })}
<span class="text-sm">{node.label}</span>
{#if loading.includes(node.id)}
<LoaderCircleIcon class="size-3.5 animate-spin" aria-label="Loading" />
{/if}
{/snippet}
</TreeView>A node with children: [] is a branch with nothing loaded yet: it can be opened. Fetch when its id
appears in onexpandedchange, then hand back nodes with the children filled in.
Data
type TreeNode<T = unknown> = {
id: string; // unique across the whole tree
label: string; // shown, and matched by typeahead
children?: TreeNode<T>[]; // omit for a leaf; [] for an empty branch
disabled?: boolean;
data?: T; // yours, handed back in onselect and the snippet
};Import
<script lang="ts">
import TreeView from '#lib/components/coral/kit/tree-view/tree-view.svelte';
</script>Props
Everything a <div> accepts stays available on the root. On top of that:
| Prop | Type | Default | Description |
|---|---|---|---|
nodes | TreeNode<T>[] | - | The top level. Required. |
expanded | string[] | [] | Bindable. Ids of the open nodes. |
selected | string | - | Bindable. Id of the selected node. |
onselect | (node: TreeNode<T>) => void | - | Click, Enter or Space. Never for disabled nodes. |
onexpandedchange | (expanded: string[]) => void | - | Nodes opened or closed. |
label | string | - | Names the tree. Or use aria-labelledby. |
locale | string | - | Typeahead's case- and accent-insensitive matching. |
toggleLabel | (node, expanded) => string | - | Tooltip on the chevron. |
class | string | - | Merged onto the root. [--coral-indent:1.5rem]. |
rowClass | string | - | Merged onto every row. |
node | Snippet<[NodeContext<T>]> | - | Replaces the row body. |
icon | Snippet<[NodeContext<T>]> | - | Replaces the chevron. |
Rows expose data-state="open|closed", data-selected and data-disabled for styling.
Accessibility
Rows are rendered flat, each carrying its level, position and set size, rather than as nested role="group"s. Assistive tech announces the same structure from those attributes, and a flat list
lets the focus ring sit on one row instead of ringing everything below it. The chevron is a mouse
target hidden from assistive tech: a second focusable control inside a tree item would break the
one-tab-stop model the pattern depends on.