Skip to content
Home Theme Gallery

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

A static tree
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
Attributes
NameTypeDefaultDescription
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.
cursoran 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.
namestring-The form field name. Each selected (or checked) value is submitted as its own entry.
guidesbooleanfalseDraws a guide line down each open group, under its parent’s marker.
disabledbooleanfalseDisables the whole tree: it leaves the tab order, ignores input and submits nothing.
Properties4
Properties
NameTypeDefaultDescription
valuesstring[]-Read-only. The selected values - or, in checkbox, the checked ones - in document order, hidden items included, read from the markup.
valuestring-The first selected value. Setting it selects (or checks) exactly that value, or exactly an array of values; no event fires.
selectionTreeSelection'single'Reflects to selection.
cursorstring | undefined-Reflects to cursor.
Events6
Events
NameDescription
inputThe 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.
changeThe selection changed, once per change, after input.
hui-expandA 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-collapseA branch closed, after aria-expanded is set. Dispatched on the item and bubbling. Detail: { value, lazy }.
hui-cursor-changeThe reader moved the cursor, after cursor is updated. Detail: { value }.
hui-activateEnter or a double-click on an enabled item. Cancellable. Detail: { value }.
Slots1
Slots
NameDescription
(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
CSS custom properties
NameDefaultDescription
--hui-tree-indent1.375remThe 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-accentoklch(0.96 0.003 325.6)The cursor row’s fill while the tree has focus.
--hui-primaryoklch(0.496 0.265 301.924)The tint behind a selected row, and a checked box’s fill.
Classes2
Classes
NameDescription
.hui-tree__markerThe 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.
<span data-label><span class="hui-tree__marker" aria-hidden="true"></span>Photos</span>
.hui-tree__checkThe 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.
<span data-label><span class="hui-tree__marker" aria-hidden="true"></span><span class="hui-tree__check" aria-hidden="true"></span>2024</span>

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.

Children loaded on demand
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.

Multiple selection
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.

Checkbox tree
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-activedescendant names the cursor item, as in hui-listbox. The element gives every item an id, and names each by its label with aria-labelledby, so a branch is not named by its children too.
  • The element writes aria-level, aria-setsize and aria-posinset on 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 checkbox mode the items carry aria-checked - mixed for 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 Highlight pair, the cursor a system outline, and the chevrons and checks are drawn in CanvasText.

Keyboard

Keyboard
KeysAction
Up, DownThe previous or next visible item, skipping disabled ones. In single it also selects.
RightA closed branch opens; an open branch moves to its first child; a leaf does nothing.
LeftAn open branch closes; anything else moves to its parent.
Home, EndThe first or last visible item.
*Opens every sibling branch at the cursor’s level.
SpaceSelects the cursor item; in multiple toggles it; in checkbox toggles its check.
Shift + Up, Down, Home, EndIn multiple, extends the range from the anchor over the visible items.
EnterEmits hui-activate for the cursor item.
Printable keysType-ahead over the visible labels.

Gotchas

  • hui-expand bubbles, 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-busy when HTMX reports the request finished (htmx:afterRequest). A host not using HTMX removes aria-busy itself. The branch stays data-lazy, so opening it again asks again.
  • In single mode, or on a plain click in multiple, selecting an item also clears a selection hidden inside a closed branch.
  • Nothing is virtualised: every item the server sends is rendered.