Tree

A keyboard-friendly tree whose branches can load lazily from the server.

zweiundeins <sb-tree> tree hierarchy lazy navigation file browser Since 2026-09-22 4.0 kB0Open in playground Edit on GitHub

Playground

A tree of items (files, categories, an org chart…). Give it the items as JSON, or let the server fill in branches when they are opened: a lazy item asks for its children with an sb-load event, and you answer by patching a signal.

Examples

Lazy loading from the server

Every branch below loads when you first open it, from the site's example dataset (/demo/data/children). Datastar does the wiring:

  1. sb-load runs @get('/demo/data/children?parent=…&into=_sky').
  2. The server answers with a signal patch, {_sky: {"sol": [children…]}}. Signal patches merge, so each loaded branch adds to the rest.
  3. data-attr:loaded hands $_sky back to the tree.

expanded opens the path to the Moon from the start: each level loads once its parent has arrived.

Selected:
<div data-signals="{_sky: {}, _picked: ''}" style="display: grid; gap: 12px; inline-size: min(100%, 22rem)">
  <sb-tree label="The sky" expanded="milkyway solarsystem earth"
    items='[{"id":"milkyway","label":"Milky Way","icon":"🌌","lazy":true},{"id":"andromeda","label":"Andromeda","icon":"🌌","lazy":true}]'
    data-attr:loaded="JSON.stringify($_sky)" data-preserve-attr="loaded"
    data-on:sb-load="@get('/demo/data/children?parent=' + evt.detail.id + '&into=_sky&delay=250')"
    data-bind:_picked__prop.value></sb-tree>
  <span>Selected: <b data-text="$_picked || 'nothing'"></b></span>
</div>

The server side is a plain Datastar handler (Go here; any language works):

func children(w http.ResponseWriter, r *http.Request) {
	id := r.URL.Query().Get("parent")
	datastar.NewSSE(w, r).MarshalAndPatchSignals(map[string]any{
		"_sky": map[string]any{id: childrenOf(id)}, // [{id, label, icon?, lazy?}]
	})
}

Without Datastar signals, the server can also re-render the element with a new loaded (or items, value, expanded) attribute: a changed attribute always wins.

Multiple selection

<sb-tree selection="multiple" value="io europa" style="inline-size: 16rem"
  items='[{"id":"jupiter","label":"Jupiter","icon":"πŸͺ","children":[{"id":"io","label":"Io"},{"id":"europa","label":"Europa"},{"id":"ganymede","label":"Ganymede"},{"id":"callisto","label":"Callisto"}]}]'></sb-tree>

With commands

Give it a name, and it emits sb-change with { name, value } when the selection changes: ready to post as a command. With confirm, it sets :state(pending) until the server's re-rendered value matches, and revert() goes back to the server's selection when a command is rejected. sb-toggle reports opened and closed branches, so the server can keep expanded too. See Commands and components.

Items

Each item is {id, label, icon?, children?, lazy?}:

  • id: unique across the tree, and a plain name (letters, digits and _), since it becomes a key in the signal.
  • icon: a short text or emoji.
  • children: nested items, given up front.
  • lazy: true without children: the item can be opened, and asks for its children with sb-load.

Accessibility

It follows the WAI-ARIA tree pattern:

  • Structure: tree and treeitem roles, with level, position and expanded state.
  • Focus: one item at a time is in the tab order.
  • Keys: Up and Down move, Right opens (or moves to the first child), Left closes (or moves to the parent), Home and End jump, and Enter or Space selects.
  • Loading: a branch that is loading is marked aria-busy.

Installation

Add Datastar with Rocket and the Starbase autoloader once per page, then use the tag. The autoloader imports each component the first time its tag appears, including tags added later by a Datastar morph.

<!-- Once per page: Datastar with Rocket, and the Starbase autoloader.
     It loads every <sb-…> component the first time its tag appears. -->
<script type="importmap">
  { "imports": { "datastar": "https://cdn.jsdelivr.net/gh/starfederation/datastar@v1.0.4/bundles/datastar-rocket.js" } }
</script>
<script type="module" src="https://starbase.zweiundeins.gmbh/c/autoloader.js"></script>
<!-- Optional, no flash of undefined elements: class="sb-cloak" on <html>, and -->
<style>.sb-cloak :not(:defined) { visibility: hidden }</style>

<sb-tree style="inline-size: 13rem" value="earth" items='[{"id":"sol","label":"Sol","icon":"β˜€οΈ","children":[{"id":"venus","label":"Venus","icon":"🟠"},{"id":"earth","label":"Earth","icon":"🌍","children":[{"id":"moon","label":"Moon","icon":"πŸŒ•"}]},{"id":"mars","label":"Mars","icon":"πŸ”΄"}]}]'></sb-tree>

<!-- In production, pin today's catalog instead of the latest: the browser then
     refuses any file that changed. Add "integrity" to the import map above: the
     hashes from https://starbase.zweiundeins.gmbh/c/@0a1ad1bf98a4/importmap.json and Datastar's, below. -->
<!--
<script type="importmap">
  { "imports": { "datastar": "https://cdn.jsdelivr.net/gh/starfederation/datastar@v1.0.4/bundles/datastar-rocket.js" },
    "integrity": { "https://cdn.jsdelivr.net/gh/starfederation/datastar@v1.0.4/bundles/datastar-rocket.js": "sha384-vUxZojLrF1Ar3de5h7VINqhJXBgjyZS4U49pHvGa87kum6j5Xn6JRziuARNw5ELG", "…": "…from importmap.json" } }
</script>
<script type="module" src="https://starbase.zweiundeins.gmbh/c/@0a1ad1bf98a4/autoloader.js" integrity="sha384-IvSCUjEWPS+wwXunU+48x96KmyWDx/Y4LeCwCL8cWFqJ/zHmTsTOsSMrzTOcpW3D"></script>
-->

<!-- Or load just this component, pinned to this version: -->
<!-- <script type="module" src="https://starbase.zweiundeins.gmbh/c/tree@fa273c59377c/tree.js" integrity="sha384-S/GsR1HV1s43182G7XedrKB6SWLTjnKPtzniVYqbW+5L+N7LNd4cY8iUuXDjJ9Ot"></script> -->

Size

Each file compressed on its own, the way it is served (gzip -9, brotli -11). Datastar and Rocket are shared by every component and not counted.

FileOriginalgzipbrotli
tree.js 11.9 kB4.6 kB4.0 kB

API reference

Props

AttributeTypeDefaultDescription
itemsjson[]The tree: [{id, label, icon?, children?: [...], lazy?: true}]. A lazy item without children asks for them with sb-load when opened.
loadedjson{}Loaded children by parent id: {"<id>": [items]}. Bind it to a signal the server patches (see the docs).
selection"single" | "multiple" | "none""single"How many items can be selected.
valuestring""Selection: an id, or ids separated by spaces (multiple). A new value from the server replaces it; the live value is the value property.
expandedstring""Open items: ids separated by spaces. A new list from the server replaces it; lazy items in it load their children.
labelstring"Tree"Accessible name.
confirmbooleanfalseServer-confirmed value: :state(pending) while the local value differs from the server's value attribute (see revert()).
namestring""Name reported in sb-change (e.g. the field of a command).

Events

NameDescription
sb-loadA lazy item was opened and has no children yet. detail: { id }. Answer by adding its children to the loaded prop.
changeThe selection changed.
sb-changeThe selection changed. detail: { name, value } (an id, or an array of ids for multiple): ready for a command.
sb-toggleAn item opened or closed. detail: { id, open }.