A tree is nested rows for things that contain things: folders, an outline, categories. Without script, a branch is a native <details> whose <summary> is the row, so it folds and unfolds with the keys the browser already gives it, and find-in-page opens a folded branch that holds a match.
tree-item is a row: a link, a button, or the <summary> of a branch. Rows are 28px, dense and quiet; the one under the pointer and the current one take a chip tone.
tree-toggle on a branch's row draws a chevron in the leading column, which turns down while the branch is open. Every row keeps that column, so a leaf lines up with the branches beside it.
tree-sub holds the rows under a branch, one level in, on a hairline that runs down from the chevron.
The WAI-ARIA tree pattern gives one tab stop and arrow keys that walk and fold the tree. That needs a script, the treebehavior, and the same classes serve it: put role="tree" on the list, role="treeitem" and aria-expanded on each branch's row, and role="group" on its tree-sub. The chevron then follows aria-expanded, a row that says aria-expanded="false" hides the tree-sub that follows it, and aria-selected="true" marks the selected row.
Driven by ARIA
<ulclass="tree max-w(260)mx-auto"role="tree"aria-label="Outline"><lirole="none"><divclass="tree-item tree-toggle"role="treeitem"aria-expanded="true"tabindex="0">Introduction</div><ulclass="tree-sub"role="group"><lirole="none"><divclass="tree-item"role="treeitem"aria-selected="true"tabindex="-1">How it fits together</div></li><lirole="none"><divclass="tree-item"role="treeitem"tabindex="-1">Principles</div></li></ul></li><lirole="none"><divclass="tree-item tree-toggle"role="treeitem"aria-expanded="false"tabindex="-1">Theme</div><ulclass="tree-sub"role="group"><lirole="none"><divclass="tree-item"role="treeitem"tabindex="-1">Surfaces</div></li></ul></li></ul><scripttype="module">import{ attach }from'@master/ui/behaviors'attach()</script>
Focus the tree and use the arrow keys. Enter, Space or a click opens a branch, or selects a row that is not one.
tree takes the root, the element with the tree class, has no options, and returns the function that detaches it.
app.js
import{ tree }from'@master/ui/behaviors/tree.js'const root = document.querySelector('.tree')tree(root)root.addEventListener('change',(event)=> console.log(event.detail.item))
One tab stop. The behavior keeps one row in the tab order with tabindex: the row the markup gave tabindex="0", or the selected or current row, or the first. A row that takes focus becomes the tab stop. Detaching gives every row back the tabindex it had.
Walking. The keys skip rows inside a closed branch, hidden rows and disabled ones.
Activation. A summary, a link and a button keep the activation the browser gives them. A row that has none, role="treeitem" on a div, is activated by Enter, Space and a click: a branch opens or closes, and a leaf becomes the selected row. aria-selected="true" moves to it from the row that had it.
The event. Selecting a row dispatches a bubbling change from the root with the row in detail.item.
Disabled. A row with disabled or aria-disabled="true" does nothing: its link is not followed and its branch stays as it is.
The behavior serves a tree of <details> too, as in the first two previews, which run without it. It opens and closes a tree-group through open, and the tree becomes one tab stop walked with the same keys. attach() attaches it to every tree; to keep a tree of <details> as the platform has it, import behaviors by name.
A nav is a short list of destinations with one level of grouping, and its current row rises as a sheet. A tree is for depth: many levels, dense rows, and a chevron that leads each branch.
With details, every row is a tab stop. With the script, the tree is one tab stop: the current or selected row, or the first
Enter, Space
Open or close a branch, or follow the link of a row; on a summary the browser does it without script. With the script, a row with role="treeitem" that is not a branch becomes the selected row
ArrowDown, ArrowUp
With the script: move to the next or the previous visible row
ArrowRight
With the script: open a closed branch; on an open branch, move to its first row. ArrowLeft from right to left
ArrowLeft
With the script: close an open branch; on a closed branch or a leaf, move to the row of its parent. ArrowRight from right to left
Home, End
With the script: move to the first or the last visible row
A character
With the script: move to the next visible row that starts with it; characters typed quickly are read as one word
*
With the script: open every closed branch at the level of the row that has focus
The tree component renders the rows from data, attaches the behavior and passes the selected row in and out. Copy it into the project with npx master-ui add tree; React, Svelte and Vue says how the components are built and used.
ui/tree.tsx
'use client'import{ useState,type ComponentProps,type ReactNode }from'react'import{ tree }from'@master/ui/behaviors/tree.js'import{ useBehavior }from'./use-behavior'exportinterfaceTreeNode{label:ReactNode/** What selecting the row gives `onValueChange`. A row with neither `href` nor `children` can be selected. */value?:string/** A row that is a link. `current` marks the page that is open. */href?:stringcurrent?:boolean/** The rows under a branch, and whether it starts open. */children?:TreeNode[]open?:booleandisabled?:boolean}exportinterfaceTreePropsextendsOmit<ComponentProps<'ul'>,'defaultValue'|'children'|'role'>{nodes:TreeNode[]/** The value of the selected row, null for none. */value?:string|nulldefaultValue?:string|nullonValueChange?:(value:string|null)=>void}/** A tree walked and folded with the arrow keys. Name it with `aria-label` or `aria-labelledby`. */exportfunctionTree({ nodes, value, defaultValue, onValueChange, className,...root }:TreeProps){const ref =useBehavior(tree,undefined,{ value, defaultValue, onValueChange })const[first]=useState(value ?? defaultValue)constrows=(list:TreeNode[]):ReactNode=> list.map((node, index)=>(<lirole="none"key={index}>{node.children?(<><divclassName="tree-item tree-toggle"role="treeitem"aria-expanded={node.open??false}aria-disabled={node.disabled||undefined}>{node.label}</div><ulclassName="tree-sub"role="group">{rows(node.children)}</ul></>): node.href?<aclassName="tree-item"role="treeitem"href={node.href}aria-current={node.current?'page':undefined}aria-disabled={node.disabled||undefined}>{node.label}</a>:<divclassName="tree-item"role="treeitem"data-value={node.value}aria-selected={(node.value!==undefined&& node.value=== first)||undefined}aria-disabled={node.disabled||undefined}>{node.label}</div>}</li>))return<ulclassName={className ?`tree ${className}`:'tree'}role="tree"ref={ref}{...root}>{rows(nodes)}</ul>}
ui/Tree.svelte
<scriptlang="ts"module>importtype{ HTMLAttributes }from'svelte/elements'exportinterfaceTreeNode{label:string/** What selecting the row gives `onValueChange`. A row with neither `href` nor `children` can be selected. */value?:string/** A row that is a link. `current` marks the page that is open. */href?:stringcurrent?:boolean/** The rows under a branch, and whether it starts open. */children?:TreeNode[]open?:booleandisabled?:boolean}exportinterfaceTreePropsextendsOmit<HTMLAttributes<HTMLUListElement>,'role'|'children'>{nodes:TreeNode[]/** The value of the selected row, null for none. Bind it, or leave it out. */value?:string|nullonValueChange?:(value:string|null)=>void}</script><scriptlang="ts">import{ untrack }from'svelte'import{ tree }from'@master/ui/behaviors/tree.js'import{ behavior }from'./behavior.svelte.js'let{ nodes, value =$bindable(), onValueChange,class: className,...root }:TreeProps=$props()const first =untrack(()=> value)const attach =behavior(tree,undefined,{value:()=> value,change:(next)=>{ value = next;onValueChange?.(next)},})</script>{#snippetrows(list: TreeNode[])}<!-- The first value's row is selected in the first render; after that the behavior writes `aria-selected`. --><!-- svelte-ignore a11y_role_has_required_aria_props -->{#each list as node}<lirole="none">{#if node.children}<divclass="tree-item tree-toggle"role="treeitem"aria-expanded={node.open??false}aria-disabled={node.disabled||undefined}>{node.label}</div><ulclass="tree-sub"role="group">{@renderrows(node.children)}</ul>{:else if node.href}<aclass="tree-item"role="treeitem"href={node.href}aria-current={node.current?'page':undefined}aria-disabled={node.disabled||undefined}>{node.label}</a>{:else}<divclass="tree-item"role="treeitem"data-value={node.value}aria-selected={(node.value!==undefined&& node.value=== first)||undefined}aria-disabled={node.disabled||undefined}>{node.label}</div>{/if}</li>{/each}{/snippet}<!--@component A tree walked and folded with the arrow keys. Name it with `aria-label` or `aria-labelledby`.--><ulclass={['tree', className]}role="tree"{@attach attach}{...root}>{@renderrows(nodes)}</ul>
ui/Tree.vue
<scriptlang="ts">exportinterfaceTreeNode{label:string/** What selecting the row gives the model. A row with neither `href` nor `children` can be selected. */value?:string/** A row that is a link. `current` marks the page that is open. */href?:stringcurrent?:boolean/** The rows under a branch, and whether it starts open. */children?:TreeNode[]open?:booleandisabled?:boolean}</script><scriptsetuplang="ts">import{ h, useTemplateRef,type FunctionalComponent }from'vue'import{ tree }from'@master/ui/behaviors/tree.js'import{ useBehavior }from'./use-behavior'defineOptions({inheritAttrs:false})defineProps<{nodes:TreeNode[]}>()/** The value of the selected row, null for none: `v-model`. */const model =defineModel<string|null>()/* A tree walked and folded with the arrow keys. Name it with `aria-label` or `aria-labelledby`. */const root =useTemplateRef<HTMLUListElement>('root')useBehavior(root, tree,undefined, model)/** The first value's row is selected in the first render; after that the behavior writes `aria-selected`. */const first = model.value/** The rows of one level, and under each branch the rows of the next. */constRows:FunctionalComponent<{nodes:TreeNode[]}>=({ nodes })=> nodes.map((node, index)=>h('li',{role:'none',key: index }, node.children?[h('div',{'class':'tree-item tree-toggle','role':'treeitem','aria-expanded': node.open??false,'aria-disabled': node.disabled||undefined}, node.label),h('ul',{class:'tree-sub',role:'group'},h(Rows,{nodes: node.children})),]: node.href?h('a',{'class':'tree-item','role':'treeitem','href': node.href,'aria-current': node.current?'page':undefined,'aria-disabled': node.disabled||undefined}, node.label):h('div',{'class':'tree-item','role':'treeitem','data-value': node.value,'aria-selected':(node.value!==undefined&& node.value=== first)||undefined,'aria-disabled': node.disabled||undefined}, node.label)))Rows.props=['nodes']</script><template><ulclass="tree"role="tree"ref="root"v-bind="$attrs"><Rows:nodes="nodes"/></ul></template>
The list; role="tree" with a label when the script drives it
tree-item
A row: a link, a button, the summary of a branch, or an element with role="treeitem". It keeps the chevron's column, so a leaf lines up with the branches beside it
tree-toggle
On the row of a branch: draws a chevron in the leading column, which turns down while the branch is open. With tree-item
tree-group
A details that holds a branch: its summary is the row, its tree-sub the rows under it
tree-sub
The rows under a branch, one level in, on a hairline that runs down from the chevron; role="group" when the script drives the tree
/* * Tree: nested rows for things that contain things: folders, outlines, categories. * Without script a branch is a native <details> whose <summary> is the row, so it folds and unfolds with the * keys the browser already gives it. With a script that provides the tree pattern, the same classes read * aria-expanded instead. Rows are dense and quiet; the current one takes a chip tone, and each level is set * in on a hairline guide. */@layer components {.tree{--tree-item-height:1.75rem;--tree-indent:1rem;--icon-size:1rem;display:flex;flex-direction:column;gap:1px;min-width:0;margin:0;padding:0;color:var(--color-text-body);font:400.8125rem/1.2var(--font-family-sans);font-feature-settings:inherit;list-style:none;}/* A row: a link, a button, the <summary> of a branch, or an element with role="treeitem". */.tree-item{--icon-offset:-.0625rem;--icon-color:var(--color-text-muted);display:flex;align-items:center;gap:.375rem;min-width:0;min-height:var(--tree-item-height);margin:0;padding-block:0;padding-inline:.25rem.5rem;border:0;border-radius:calc(var(--radius-chip)*var(--corner-scale));corner-shape:var(--corner-shape);outline:none;background:transparent;color:inherit;font:inherit;font-feature-settings:inherit;text-align:start;text-decoration:none;white-space:nowrap;list-style:none;appearance:none;user-select:none;-webkit-user-select:none;-webkit-tap-highlight-color:transparent;cursor:pointer;touch-action:manipulation;&::-webkit-details-marker{display:none;}/* Every row keeps the chevron's column, so a leaf lines up with the branches beside it. */&::before{content:'';flex:none;width:.875rem;height:.875rem;}@media(hover:hover){&:hover:not([aria-disabled="true"]){background:var(--color-surface-chip);color:var(--color-text-strong);}}/* Pressed: a step deeper than the pointer's tone. */&:active:not([aria-disabled="true"]){background:var(--color-surface-inset-hover);color:var(--color-text-strong);}&:is([aria-current="page"],[aria-current="true"],[aria-selected="true"]){--icon-color:currentColor;background:var(--color-surface-chip);color:var(--color-text-strong);}&:focus-visible{outline:2pxsolidvar(--color-focus);outline-offset:-2px;}&[aria-disabled="true"]{--icon-color:currentColor;color:var(--color-text-disabled);cursor:not-allowed;}@media(pointer:coarse){min-height:max(var(--tree-item-height),2.75rem);}@media(forced-colors:active){&:is([aria-current="page"],[aria-current="true"],[aria-selected="true"]){outline:2pxsolid Highlight;outline-offset:-2px;}}}/* The row of a branch: its column holds a chevron that turns down when the branch is open. */.tree-toggle::before{background:var(--color-text-muted);mask:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6.5 4.5 3.5 3.5-3.5 3.5'/%3E%3C/svg%3E")center/containno-repeat;transition: rotate var(--duration-quick)var(--easing-glide);@media(prefers-reduced-motion:reduce){transition:none;}@media(forced-colors:active){background: CanvasText;}}.tree-toggle:dir(rtl)::before{scale:-11;}/* Open: by its <details>, or by aria-expanded. A closed branch inside an open one keeps its own chevron. */.tree-group[open].tree-toggle::before,.tree-toggle[aria-expanded="true"]::before{rotate:90deg;}.tree-group[open].tree-group:not([open]).tree-toggle::before{rotate:none;}/* A <details> that holds a branch. */.tree-group{display:flex;flex-direction:column;gap:1px;min-width:0;}/* The rows under a branch, one level in, on a hairline that runs down from the chevron. */.tree-sub{display:flex;flex-direction:column;gap:1px;margin:0;margin-inline-start:calc(.25rem+.4375rem-.5px);padding:0;padding-inline-start:calc(var(--tree-indent)-.25rem-.4375rem-.5px);border-inline-start:1pxsolidvar(--color-line-hair);list-style:none;}/* Driven by script: a row that says it is collapsed hides the rows that follow it. */.tree-item[aria-expanded="false"]+.tree-sub{display:none;}}