Tree
The custom element is <hui-tree>.
Overview
A tree view over nested items the server rendered: indentation, expand and collapse, the tree’s arrow keys and children the host loads on demand. The items’ aria-expanded and aria-selected are the state, so a swap is the whole update.
Example
Source
<hui-tree name="destination" aria-label="Destinations" guides style="max-block-size:20rem"> <div role="treeitem" data-value="photos" aria-expanded="true"> <span data-label>Photos</span> <div role="group"> <div role="treeitem" data-value="photos/2024" aria-selected="true"><span data-label>2024</span></div> <div role="treeitem" data-value="photos/2025"><span data-label>2025</span></div> <div role="treeitem" data-value="photos/scans" aria-expanded="false"> <span data-label>Scans</span> <div role="group"> <div role="treeitem" data-value="photos/scans/receipts"><span data-label>Receipts</span></div> <div role="treeitem" data-value="photos/scans/letters"><span data-label>Letters</span></div> </div> </div> </div> </div> <div role="treeitem" data-value="music" aria-expanded="false"> <span data-label>Music</span> <div role="group"> <div role="treeitem" data-value="music/jazz"><span data-label>Jazz</span></div> <div role="treeitem" data-value="music/rock"><span data-label>Rock</span></div> </div> </div> <div role="treeitem" data-value="documents" aria-expanded="true"> <span data-label>Documents</span> <div role="group"> <div role="treeitem" data-value="documents/tax"><span data-label>Tax</span></div> <div role="treeitem" data-value="documents/locked" aria-disabled="true"><span data-label>Locked</span></div> </div> </div> <div role="treeitem" data-value="notes"><span data-label>Notes</span></div></hui-tree>The Go template that renders it:
{{ define "node" }} <div role="treeitem" data-value="{{ .Path }}" {{- if .Folder }} aria-expanded="{{ .Open }}"{{ end }} {{- if .Selected }} aria-selected="true"{{ end }}> <span data-label>{{ .Name }}</span> {{ if .Folder }}<div role="group">{{ range .Children }}{{ template "node" . }}{{ end }}</div>{{ end }} </div>{{ end }}
<hui-tree name="destination" aria-label="Destinations" guides{{ if .Cursor }} cursor="{{ .Cursor }}"{{ end }}> {{ range .Roots }}{{ template "node" . }}{{ end }}</hui-tree>API
Attributes5
| Name | Type | Default | Description |
|---|---|---|---|
selection | "none" | "single" | "multiple" | "checkbox" | "single" | Selection mode. multiple sets aria-multiselectable; checkbox draws a checkbox per row and uses aria-checked. In single the selection follows the cursor. |
cursor | an item’s value | - | The item the keys act on. Viewer-owned: reflected as the reader moves, and accepted back from a server that persisted it. |
name | string | - | The form field name. Each selected (or checked) value is submitted as its own entry. |
guides | boolean | false | Draws a guide line down each open group, under its parent’s marker. |
disabled | boolean | false | Disables the whole tree: it leaves the tab order, ignores input and submits nothing. |
Properties4
| Name | Type | Default | Description |
|---|---|---|---|
values | string[] | - | Read-only. The selected values - or, in checkbox, the checked ones - in document order, hidden items included, read from the markup. |
value | string | - | The first selected value. Setting it selects (or checks) exactly that value, or exactly an array of values; no event fires. |
selection | TreeSelection | 'single' | Reflects to selection. |
cursor | string | undefined | - | Reflects to cursor. |
Events6
| Name | Description |
|---|---|
input | The selection changed. bubbles and composed. In multiple and checkbox it is a CustomEvent whose detail is the values array, which Alpine’s x-model binds. Once per change, a whole cascade included. |
change | The selection changed, once per change, after input. |
hui-expand | A branch opened, after aria-expanded is set. Dispatched on the item and bubbling, so hx-trigger on the item works. Cancellable: cancelling closes the branch again. Detail: { value, lazy }, where lazy says the host must load its children. |
hui-collapse | A branch closed, after aria-expanded is set. Dispatched on the item and bubbling. Detail: { value, lazy }. |
hui-cursor-change | The reader moved the cursor, after cursor is updated. Detail: { value }. |
hui-activate | Enter or a double-click on an enabled item. Cancellable. Detail: { value }. |
Slots1
| Name | Description |
|---|---|
(default) | Nested [role="treeitem"] items. Each has a [data-label] child, its row, and a branch has aria-expanded and a [role="group"] child holding its children. Light DOM: there is no shadow root. |
CSS custom properties4
| Name | Default | Description |
|---|---|---|
--hui-tree-indent | 1.375rem | The indent per level: the marker and the gap, so a child’s marker sits under its parent’s label. |
--hui-tree-level | - | Written by the element on every item: its level, 1 for a root. The row’s indent is computed from it. |
--hui-accent | oklch(0.96 0.003 325.6) | The cursor row’s fill while the tree has focus. |
--hui-primary | oklch(0.496 0.265 301.924) | The tint behind a selected row, and a checked box’s fill. |
Classes2
| Name | Description |
|---|---|
.hui-tree__marker | The marker hui-tree writes at the start of every row: Remix’s arrow-right-s-line on a branch, turned a quarter while it is open and a spinner while aria-busy; an empty spacer the same width on a leaf. Clicking it opens or closes the branch. |
.hui-tree__check | The checkbox hui-tree draws in each row with selection="checkbox", at hui-checkbox’s nova geometry, following the item’s aria-checked. Decoration: the tree keeps focus. |
States
Children loaded on demand
A branch marked data-lazy with an empty group emits hui-expand when it opens, with lazy: true in its detail, and is aria-busy - the marker becomes a spinner - until the host fills the group. The element then clears data-lazy. This example plays the host with a timer; with HTMX the branch itself carries hx-get, hx-trigger="hui-expand[detail.lazy&&target===this]" and hx-target="find [role=group]". The empty folder answers with nothing, and stays closable.
Source
<hui-tree id="lazy-tree" aria-label="Library"> <div role="treeitem" data-value="home" aria-expanded="true"> <span data-label>Home</span> <div role="group"> <div role="treeitem" data-value="home/scans" aria-expanded="false" data-lazy> <span data-label>Scans</span> <div role="group"></div> </div> <div role="treeitem" data-value="home/empty" aria-expanded="false" data-lazy> <span data-label>Empty folder</span> <div role="group"></div> </div> </div> </div> <div role="treeitem" data-value="shared"><span data-label>Shared</span></div></hui-tree><script> // The host: fetch the children when a lazy branch opens, then fill its group. document.querySelector('#lazy-tree').addEventListener('hui-expand', (event) => { if (!event.detail.lazy) return; const item = event.target; setTimeout(() => { if (event.detail.value === 'home/empty') { item.removeAttribute('aria-busy'); return; } item.querySelector(':scope > [role="group"]').innerHTML = ['2023', '2024', '2025'] .map((year) => '<div role="treeitem" data-value="' + event.detail.value + '/' + year + '"><span data-label>' + year + '</span></div>') .join(''); }, 800); });</script>Multiple selection
With selection="multiple" the cursor moves freely and Space marks items, as in hui-listbox: Shift with an arrow, Home or End extends a range from the anchor, Ctrl or Cmd + click toggles one and Shift + click selects a range. A range walks the visible items only, so it never reaches into a closed branch. A form receives one sources entry per selected item.
Source
<hui-tree name="sources" selection="multiple" aria-label="Sources" style="max-block-size:15rem"> <div role="treeitem" data-value="local" aria-expanded="true"> <span data-label>Local</span> <div role="group"> <div role="treeitem" data-value="local/home" aria-selected="true"><span data-label>Home</span></div> <div role="treeitem" data-value="local/scans"><span data-label>Scans</span></div> <div role="treeitem" data-value="local/phone" aria-selected="true"><span data-label>Phone backup</span></div> </div> </div> <div role="treeitem" data-value="remote" aria-expanded="true"> <span data-label>Remote</span> <div role="group"> <div role="treeitem" data-value="remote/server"><span data-label>Media server</span></div> <div role="treeitem" data-value="remote/offsite" aria-expanded="false"> <span data-label>Offsite</span> <div role="group"> <div role="treeitem" data-value="remote/offsite/2024"><span data-label>2024</span></div> </div> </div> </div> </div></hui-tree>Checkbox tree
With selection="checkbox" every row carries a checkbox and the state is aria-checked. Checking a branch checks everything loaded beneath it; a branch shows checked, mixed or clear from its enabled children, recomputed after every change and every swap. A lazy branch keeps the server’s state until its children arrive. Space or the box toggles; the label only moves the cursor. The form submits every checked item, branches included.
Source
<hui-tree name="include" selection="checkbox" aria-label="Include in backup"> <div role="treeitem" data-value="photos" aria-expanded="true"> <span data-label>Photos</span> <div role="group"> <div role="treeitem" data-value="photos/2024" aria-checked="true"><span data-label>2024</span></div> <div role="treeitem" data-value="photos/2025"><span data-label>2025</span></div> <div role="treeitem" data-value="photos/raw" aria-disabled="true"><span data-label>Raw files</span></div> </div> </div> <div role="treeitem" data-value="music" aria-expanded="false"> <span data-label>Music</span> <div role="group"> <div role="treeitem" data-value="music/jazz" aria-checked="true"><span data-label>Jazz</span></div> <div role="treeitem" data-value="music/rock" aria-checked="true"><span data-label>Rock</span></div> </div> </div> <div role="treeitem" data-value="archive" aria-expanded="false" aria-checked="true" data-lazy> <span data-label>Archive</span> <div role="group"></div> </div> <div role="treeitem" data-value="notes"><span data-label>Notes</span></div></hui-tree>Server-side mechanics
The server owns the items and which of them are selected, open or checked, written as aria-selected, aria-expanded and aria-checked - there is no value attribute to disagree with the markup. The tree submits each selected value under name, hidden ones included, and a form reset restores what the server last rendered. Which branches are open is the reader’s to change but the server’s to render: the element writes and announces aria-expanded and keeps no copy. Loading children is the host’s job - a data-lazy branch asks with hui-expand and the host fills its group. A swap of one group recomputes that branch’s levels and leaves expansion elsewhere alone; a swap of the whole tree takes the server’s expansion and selection, and the cursor finds its value again.
Accessibility
- Focus stays on the tree and
aria-activedescendantnames the cursor item, as inhui-listbox. The element gives every item anid, and names each by its label witharia-labelledby, so a branch is not named by its children too. - The element writes
aria-level,aria-setsizeandaria-posinseton every item, after every render and swap, because browsers infer them from nesting inconsistently. - Collapsing a branch that holds the cursor moves the cursor to the branch, so it is never on an item the reader cannot see.
- In
checkboxmode the items carryaria-checked-mixedfor a partly checked branch - and the tree is not multiselectable, as the ARIA tree pattern describes. - In forced colours a selected row takes the system
Highlightpair, the cursor a system outline, and the chevrons and checks are drawn inCanvasText.
Keyboard
| Keys | Action |
|---|---|
Up, Down | The previous or next visible item, skipping disabled ones. In single it also selects. |
Right | A closed branch opens; an open branch moves to its first child; a leaf does nothing. |
Left | An open branch closes; anything else moves to its parent. |
Home, End | The first or last visible item. |
* | Opens every sibling branch at the cursor’s level. |
Space | Selects the cursor item; in multiple toggles it; in checkbox toggles its check. |
Shift + Up, Down, Home, End | In multiple, extends the range from the anchor over the visible items. |
Enter | Emits hui-activate for the cursor item. |
Printable keys | Type-ahead over the visible labels. |
Gotchas
hui-expandbubbles, so a nested branch opening passes through its ancestors. A lazy branch loaded with HTMX filters its trigger -hui-expand[detail.lazy&&target===this]- so it asks only while unloaded, and only for itself.- An empty answer swapped into an empty group changes nothing the element can observe; it also clears
aria-busywhen HTMX reports the request finished (htmx:afterRequest). A host not using HTMX removesaria-busyitself. The branch staysdata-lazy, so opening it again asks again. - In
singlemode, or on a plain click inmultiple, selecting an item also clears a selection hidden inside a closed branch. - Nothing is virtualised: every item the server sends is rendered.