Components
tree
A hierarchical role="tree" built from nested <ul>/<li> lists. Parent nodes carry aria-expanded and hold a nested role="group"; the APG keyboard contract and roving tabindex live in public/site.js.
Installation
1. Install via the shadcn CLI
npx shadcn@latest add http://localhost/r/tree.json2. Use it
import { Tree, TreeItem, TreeGroup } from "@/components/ui/tree"
<Tree ariaLabel="File system">
<TreeItem label="Projects" expanded>
<TreeGroup>
<TreeItem label="project-1.docx" />
<TreeItem label="drafts" parent>
<TreeGroup>
<TreeItem label="draft-a.docx" />
</TreeGroup>
</TreeItem>
</TreeGroup>
</TreeItem>
<TreeItem label="README.md" />
</Tree>Or copy the source manually
/** @jsxImportSource hono/jsx */
import type { PropsWithChildren } from "hono/jsx"
import { cn, type ClassValue } from "@/registry/lib/cn"
// Tree (Tree View) — shadcn-htmx, htmx v4 + Tailwind v4.
//
// shadcn/ui has no first-party Tree primitive; community trees lean on Radix
// Accordion or a headless library. We instead build the exact WAI-ARIA APG
// Tree View widget from real list semantics:
// repos/aria-practices/content/patterns/treeview/treeview-pattern.html
// repos/aria-practices/content/patterns/treeview/examples/treeview-1b.html
// (the "declared properties" file-directory example we model 1:1)
// repos/aria-practices/content/patterns/treeview/examples/js/tree.js
// repos/aria-practices/content/patterns/treeview/examples/js/treeitem.js
// (the keyboard contract our site.js handler is modelled on)
// Role semantics confirmed against MDN:
// repos/mdn/files/en-us/web/accessibility/aria/reference/roles/tree_role
// repos/mdn/files/en-us/web/accessibility/aria/reference/roles/treeitem_role
// repos/mdn/files/en-us/web/accessibility/aria/reference/roles/group_role
//
// Anatomy (web-standard nested lists, the structure APG recommends):
// <ul role="tree"> ← TreeRoot, data-slot="tree"
// <li role="treeitem" aria-expanded> ← TreeItem (parent node)
// <span data-slot="tree-label">…</span>
// <ul role="group"> ← TreeGroup
// <li role="treeitem">…</li> ← TreeItem (end node, no aria-expanded)
// </ul>
// </li>
// </ul>
//
// Why not native <details>/<summary> underneath? APG requires the treeitem
// (the <li>) to be the focusable element managed by a single ROVING tabindex —
// exactly one node is in the tab sequence, arrows move that 0 between visible
// nodes. A <summary> is independently focusable and would create competing tab
// stops that the roving model can't reconcile, so the platform's disclosure
// widget is the wrong primitive here. We keep the open/closed state on the
// node's aria-expanded attribute (CSS hides the collapsed <ul role="group">),
// which is exactly what the APG example does. The end result still degrades
// without JS: every group is visible (expanded) so the content stays reachable.
//
// public/site.js (keyed on data-slot="tree") owns the live APG keyboard +
// roving-tabindex contract: Up/Down move between VISIBLE nodes, Right expands /
// steps into the first child, Left collapses / steps to the parent, Home/End
// jump to first/last visible node, type-ahead matches node labels, `*` expands
// all siblings, Enter/Space select. An inline boot <script> seeds the roving
// tabindex (first node tabindex="0") before paint so there's no flash.
export type TreeProps = PropsWithChildren<{
// Accessible name. APG: the tree element must be labelled via aria-label or
// aria-labelledby. Pass one of these (ariaLabelledby points at a heading id).
ariaLabel?: string
ariaLabelledby?: string
class?: ClassValue
id?: string
// htmx / data / aria passthrough onto the <ul role="tree">.
[key: `hx-${string}`]: any
[key: `data-${string}`]: any
[key: `aria-${string}`]: any
}>
// Collapsing closed parents (hide the child group of an aria-expanded=false
// node), the focus ring, hover, selected fill, disabled dimming, and chevron
// rotation are all handled by CSS attribute selectors scoped to
// [data-slot="tree"] in styles.css — see newThemeTokens. So the utility
// classes here are just layout + base text.
const treeBase = "w-fit min-w-56 select-none text-sm text-foreground"
export function Tree(props: TreeProps) {
const { ariaLabel, ariaLabelledby, class: className, children, ...rest } =
props as any
// Seed the roving tabindex before paint: the first treeitem becomes the
// single tab stop (tabindex="0"), every other node gets -1. Runs right after
// this element is parsed so the tree is a single tab stop immediately, with
// no flash of all-focusable nodes before site.js boots. Models the APG
// example's `firstTreeitem.domNode.tabIndex = 0` initialisation.
const boot = `(function(el){
var items = el.querySelectorAll('[role="treeitem"]');
items.forEach(function(it,i){ it.setAttribute('tabindex', i===0?'0':'-1'); });
el.setAttribute('data-tree-ready','true');
})(document.currentScript.previousElementSibling);`
return (
<>
<ul
role="tree"
data-slot="tree"
aria-label={ariaLabel}
aria-labelledby={ariaLabelledby}
class={cn(treeBase, className)}
{...rest}
>
{children}
</ul>
<script
// biome-ignore lint/security/noDangerouslySetInnerHtml: SSR boot
dangerouslySetInnerHTML={{ __html: boot }}
/>
</>
)
}
export type TreeItemProps = PropsWithChildren<{
// Stable identifier emitted as data-value so consumers / htmx can target the
// node. Not required for the ARIA contract.
value?: string
// Parent node: when set, this treeitem gets aria-expanded and is expected to
// contain a <TreeGroup>. End nodes leave it undefined so they are NOT
// mis-described as parents (APG: end nodes have no aria-expanded).
expanded?: boolean
// Mark a parent node without pre-expanding it. Forces aria-expanded="false".
parent?: boolean
// Single-select trees indicate the chosen node with aria-selected.
selected?: boolean
// Mark the node that represents the current page/location (distinct from
// `selected`: current = where you ARE, selected = what an action targets).
// Emits aria-current; the navigation treeview example marks the current-page
// node this way. APG treeview-navigation example (aria-current="page").
current?: boolean | "page"
// aria-level / aria-posinset / aria-setsize. REQUIRED only for lazy-loaded
// (htmx) trees where the full node set is not yet in the DOM — the browser
// cannot then compute position/size from a missing subtree. Static, fully
// rendered trees can omit these and rely on browser computation.
// APG treeview pattern + MDN treeitem role (required under dynamic loading);
// declared explicitly in the treeview-1b "declared properties" example.
level?: number
posinset?: number
setsize?: number
disabled?: boolean
// Visible label. Prefer this over free children so type-ahead has clean text;
// children render after it (e.g. a nested <TreeGroup>).
label?: any
class?: ClassValue
[key: `hx-${string}`]: any
[key: `data-${string}`]: any
[key: `aria-${string}`]: any
}>
// The roving-tabindex node is the <li role="treeitem">; the visual focus ring,
// hover, selected fill, disabled dimming, and chevron rotation are all driven
// off the node's state attributes by plain CSS attribute selectors scoped to
// [data-slot="tree"] (appended to styles.css). This mirrors the APG example,
// which notes the open/closed and selected states are "synchronized by a CSS
// attribute selector" rather than per-element classes — and keeps the markup
// identical and tiny across all five flavours.
const treeItemLabel =
"flex cursor-pointer items-center gap-1.5 rounded-sm px-2 py-1.5 outline-none"
export function TreeItem(props: TreeItemProps) {
const {
value,
expanded,
parent,
selected,
current,
level,
posinset,
setsize,
disabled,
label,
class: className,
children,
...rest
} = props as any
// aria-current: "page" is the canonical value for the current-location node
// (APG treeview-navigation); accept the boolean shorthand for current=true.
const ariaCurrent =
current === true ? "page" : current === false ? undefined : current
// A node is a parent when it is explicitly marked `parent` or given an
// `expanded` value. End nodes get NO aria-expanded (APG: an end node with
// aria-expanded would be mis-announced as a parent). We require the flag
// rather than sniffing for a child group so every flavour decides this the
// same deterministic way at render time.
const isParent = parent === true || expanded !== undefined
const ariaExpanded = isParent ? (expanded ? "true" : "false") : undefined
return (
<li
role="treeitem"
data-slot="tree-item"
data-value={value}
// Seeded by the boot script / managed by site.js (roving tabindex).
tabindex="-1"
aria-expanded={ariaExpanded}
aria-selected={selected ? "true" : "false"}
aria-current={ariaCurrent}
aria-level={level}
aria-posinset={posinset}
aria-setsize={setsize}
aria-disabled={disabled ? "true" : undefined}
class={cn(className)}
{...rest}
>
<span data-slot="tree-label" class={treeItemLabel}>
{isParent ? (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
data-slot="tree-chevron"
class="pointer-events-none size-4 shrink-0 text-muted-foreground transition-transform duration-200"
aria-hidden="true"
>
<polyline points="9 18 15 12 9 6" />
</svg>
) : (
<span aria-hidden="true" class="size-4 shrink-0" />
)}
{label}
</span>
{children}
</li>
)
}
export type TreeGroupProps = PropsWithChildren<{ class?: ClassValue }>
// The child container of a parent node. role="group" is what lets the browser
// compute aria-level / aria-setsize / aria-posinset from the DOM and keeps the
// children out of the parent's accessible name (APG group role notes).
export function TreeGroup(props: TreeGroupProps) {
return (
<ul
role="group"
data-slot="tree-group"
// Indent the branch; the connecting rail reads as hierarchy.
class={cn("ml-3.5 border-l border-border pl-1.5", props.class)}
>
{props.children}
</ul>
)
}
1. Save the file
Copy tree.html into templates/components/.
2. Use it
{% from "components/tree.html" import tree_open, tree_close,
tree_item_open, tree_item_close, tree_group_open, tree_group_close %}
{{ tree_open(aria_label="File system") }}
{{ tree_item_open(label="Projects", expanded=true) }}
{{ tree_group_open() }}
{{ tree_item_open(label="project-1.docx") }}{{ tree_item_close() }}
{{ tree_group_close() }}
{{ tree_item_close() }}
{{ tree_close() }}View source
{# Tree (Tree View) macros — shadcn-htmx, htmx v4 + Tailwind v4.
Mirrors registry/ui/tree.tsx 1:1 (same elements, roles, ARIA, data-slot,
classes). WAI-ARIA APG Tree View pattern; the live keyboard + roving
tabindex contract lives in public/site.js keyed on data-slot="tree".
Usage:
{% from "components/tree.html" import tree_open, tree_close,
tree_item_open, tree_item_close, tree_group_open, tree_group_close %}
{{ tree_open(aria_label="File system") }}
{{ tree_item_open(label="Projects", expanded=true) }}
{{ tree_group_open() }}
{{ tree_item_open(label="project-1.docx") }}{{ tree_item_close() }}
{{ tree_group_close() }}
{{ tree_item_close() }}
{{ tree_close() }} #}
{% macro tree_open(aria_label="", aria_labelledby="", id="", extra_class="", attrs={}) -%}
<ul role="tree" data-slot="tree"
{%- if id %} id="{{ id }}"{% endif %}
{%- if aria_label %} aria-label="{{ aria_label }}"{% endif %}
{%- if aria_labelledby %} aria-labelledby="{{ aria_labelledby }}"{% endif %}
{%- for k, v in attrs.items() %} {{ k|replace('_','-') }}="{{ v }}"{% endfor %}
class="w-fit min-w-56 select-none text-sm text-foreground {{ extra_class }}">
<script>(function(el){var items=el.querySelectorAll('[role="treeitem"]');items.forEach(function(it,i){it.setAttribute('tabindex',i===0?'0':'-1');});el.setAttribute('data-tree-ready','true');})(document.currentScript.previousElementSibling);</script>
{%- endmacro %}
{% macro tree_close() %}</ul>{% endmacro %}
{# current: "page" (or true → "page") marks the current-location node — APG
treeview-navigation example, distinct from `selected`.
level/posinset/setsize emit aria-level/-posinset/-setsize, REQUIRED only for
lazy-loaded (htmx) trees whose full node set is not yet in the DOM (APG
treeview pattern + MDN treeitem role); declared in the treeview-1b example. #}
{% macro tree_item_open(label="", value="", parent=false, expanded=none, selected=false, current=none, level=none, posinset=none, setsize=none, disabled=false, extra_class="", attrs={}) -%}
{%- set is_parent = parent or expanded is not none -%}
{%- set aria_current = ('page' if current is sameas true else (none if current is sameas false else current)) -%}
<li role="treeitem" data-slot="tree-item"
{%- if value %} data-value="{{ value }}"{% endif %}
tabindex="-1"
{%- if is_parent %} aria-expanded="{{ 'true' if expanded else 'false' }}"{% endif %}
aria-selected="{{ 'true' if selected else 'false' }}"
{%- if aria_current is not none %} aria-current="{{ aria_current }}"{% endif %}
{%- if level is not none %} aria-level="{{ level }}"{% endif %}
{%- if posinset is not none %} aria-posinset="{{ posinset }}"{% endif %}
{%- if setsize is not none %} aria-setsize="{{ setsize }}"{% endif %}
{%- if disabled %} aria-disabled="true"{% endif %}
{%- for k, v in attrs.items() %} {{ k|replace('_','-') }}="{{ v }}"{% endfor %}
class="{{ extra_class }}">
<span data-slot="tree-label"
class="flex cursor-pointer items-center gap-1.5 rounded-sm px-2 py-1.5 outline-none">
{%- if is_parent %}
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
data-slot="tree-chevron"
class="pointer-events-none size-4 shrink-0 text-muted-foreground transition-transform duration-200" aria-hidden="true">
<polyline points="9 18 15 12 9 6" />
</svg>
{%- else %}
<span aria-hidden="true" class="size-4 shrink-0"></span>
{%- endif %}
{{ label }}
</span>
{%- endmacro %}
{% macro tree_item_close() %}</li>{% endmacro %}
{% macro tree_group_open(extra_class="") -%}
<ul role="group" data-slot="tree-group" class="ml-3.5 border-l border-border pl-1.5 {{ extra_class }}">
{%- endmacro %}
{% macro tree_group_close() %}</ul>{% endmacro %}
1. Save the file
Add tree.tmpl alongside your other templates.
2. Use it
{{template "tree" (dict
"AriaLabel" "File system"
"Body" (htmlSafe `
{{template "tree_item" (dict
"Label" "Projects" "Expanded" true
"Body" (htmlSafe `{{template "tree_group" (dict "Body" (htmlSafe `
{{template "tree_item" (dict "Label" "project-1.docx")}}
`))}}`)
)}}`)
)}}View source
{{/*
Tree (Tree View) templates — shadcn-htmx, htmx v4 + Tailwind v4.
Mirrors registry/ui/tree.tsx 1:1 (same elements, roles, ARIA, data-slot,
classes). WAI-ARIA APG Tree View pattern; the live keyboard + roving
tabindex contract lives in public/site.js keyed on data-slot="tree".
Named templates compose:
- "tree" — the <ul role="tree"> wrapper (call with Body, AriaLabel)
- "tree_item" — one <li role="treeitem"> (Label, Expanded, Body for group)
- "tree_group" — the nested <ul role="group"> (call with Body)
Usage:
{{template "tree" (dict
"AriaLabel" "File system"
"Body" (htmlSafe `
{{template "tree_item" (dict
"Label" "Projects" "Expanded" true
"Body" (htmlSafe `{{template "tree_group" (dict "Body" (htmlSafe `
{{template "tree_item" (dict "Label" "project-1.docx")}}
`))}}`)
)}}`)
)}}
*/}}
{{define "tree"}}
<ul role="tree" data-slot="tree"
{{if .ID}}id="{{.ID}}"{{end}}
{{if .AriaLabel}}aria-label="{{.AriaLabel}}"{{end}}
{{if .AriaLabelledby}}aria-labelledby="{{.AriaLabelledby}}"{{end}}
class="w-fit min-w-56 select-none text-sm text-foreground {{or .Class ""}}">
{{.Body}}
</ul>
<script>(function(el){var items=el.querySelectorAll('[role="treeitem"]');items.forEach(function(it,i){it.setAttribute('tabindex',i===0?'0':'-1');});el.setAttribute('data-tree-ready','true');})(document.currentScript.previousElementSibling);</script>
{{end}}
{{/*
Current ("page", or true → "page") marks the current-location node — APG
treeview-navigation example, distinct from Selected.
Level/Posinset/Setsize emit aria-level/-posinset/-setsize, REQUIRED only for
lazy-loaded (htmx) trees whose full node set is not yet in the DOM (APG
treeview pattern + MDN treeitem role); declared in the treeview-1b example.
*/}}
{{define "tree_item"}}
{{- $isParent := or .Parent (ne (printf "%v" .Expanded) "<no value>") -}}
<li role="treeitem" data-slot="tree-item"
{{if .Value}}data-value="{{.Value}}"{{end}}
tabindex="-1"
{{if $isParent}}aria-expanded="{{if .Expanded}}true{{else}}false{{end}}"{{end}}
aria-selected="{{if .Selected}}true{{else}}false{{end}}"
{{if eq (printf "%v" .Current) "true"}}aria-current="page"{{else if and .Current (ne (printf "%v" .Current) "<no value>") (ne (printf "%v" .Current) "false")}}aria-current="{{.Current}}"{{end}}
{{if and .Level (ne (printf "%v" .Level) "<no value>")}}aria-level="{{.Level}}"{{end}}
{{if and .Posinset (ne (printf "%v" .Posinset) "<no value>")}}aria-posinset="{{.Posinset}}"{{end}}
{{if and .Setsize (ne (printf "%v" .Setsize) "<no value>")}}aria-setsize="{{.Setsize}}"{{end}}
{{if .Disabled}}aria-disabled="true"{{end}}
class="{{or .Class ""}}">
<span data-slot="tree-label"
class="flex cursor-pointer items-center gap-1.5 rounded-sm px-2 py-1.5 outline-none">
{{if $isParent}}
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
data-slot="tree-chevron"
class="pointer-events-none size-4 shrink-0 text-muted-foreground transition-transform duration-200" aria-hidden="true">
<polyline points="9 18 15 12 9 6" />
</svg>
{{else}}
<span aria-hidden="true" class="size-4 shrink-0"></span>
{{end}}
{{.Label}}
</span>
{{.Body}}
</li>
{{end}}
{{define "tree_group"}}
<ul role="group" data-slot="tree-group" class="ml-3.5 border-l border-border pl-1.5 {{or .Class ""}}">
{{.Body}}
</ul>
{{end}}
1. Save the file
Drop tree.ex into lib/my_app_web/components/.
2. Use it
<.tree aria_label="File system">
<.tree_item label="Projects" expanded>
<.tree_group>
<.tree_item label="project-1.docx" />
</.tree_group>
</.tree_item>
</.tree>View source
defmodule ShadcnHtmx.Components.Tree do
@moduledoc """
Tree (Tree View) — shadcn-htmx, htmx v4 + Tailwind v4 for Phoenix.
Web-standard nested lists wired to the WAI-ARIA APG Tree View pattern:
`<ul role="tree">` containing `<li role="treeitem">` nodes; parent nodes
carry `aria-expanded` and hold a nested `<ul role="group">`. End nodes have
no `aria-expanded` so they are not mis-described as parents.
The live keyboard + roving-tabindex contract (Up/Down between visible nodes,
Right expand / into child, Left collapse / to parent, Home/End, type-ahead,
`*` expand siblings, Enter/Space select) lives in public/site.js keyed on
`data-slot="tree"`. An inline boot script seeds the roving tabindex before
paint so the tree is a single tab stop with no flash.
## Examples
<.tree aria_label="File system">
<.tree_item label="Projects" expanded>
<.tree_group>
<.tree_item label="project-1.docx" />
</.tree_group>
</.tree_item>
</.tree>
"""
use Phoenix.Component
attr :id, :string, default: nil
attr :aria_label, :string, default: nil
attr :aria_labelledby, :string, default: nil
attr :class, :string, default: nil
attr :rest, :global
slot :inner_block, required: true
def tree(assigns) do
~H"""
<ul
id={@id}
role="tree"
data-slot="tree"
aria-label={@aria_label}
aria-labelledby={@aria_labelledby}
class={["w-fit min-w-56 select-none text-sm text-foreground", @class]}
{@rest}
>
{render_slot(@inner_block)}
</ul>
<%= Phoenix.HTML.raw(~s(<script>(function(el){var items=el.querySelectorAll('[role="treeitem"]');items.forEach(function(it,i){it.setAttribute('tabindex',i===0?'0':'-1');});el.setAttribute('data-tree-ready','true');})(document.currentScript.previousElementSibling);</script>)) %>
"""
end
attr :label, :string, default: nil
attr :value, :string, default: nil
attr :parent, :boolean, default: false
# nil means "end node" (no aria-expanded); true/false means a parent node.
attr :expanded, :boolean, default: nil
attr :selected, :boolean, default: false
# current: "page" (the canonical current-location value) or true → "page".
# Distinct from selected (current = where you ARE) — APG treeview-navigation.
attr :current, :any, default: nil
# aria-level/-posinset/-setsize. REQUIRED only for lazy-loaded (htmx) trees
# whose full node set is not yet in the DOM (APG treeview pattern + MDN
# treeitem role); declared in the treeview-1b example.
attr :level, :integer, default: nil
attr :posinset, :integer, default: nil
attr :setsize, :integer, default: nil
attr :disabled, :boolean, default: false
attr :class, :string, default: nil
attr :rest, :global
slot :inner_block
def tree_item(assigns) do
assigns =
assigns
|> assign(:is_parent, assigns.parent || assigns.expanded != nil)
|> assign(
:aria_current,
case assigns.current do
true -> "page"
false -> nil
other -> other
end
)
~H"""
<li
role="treeitem"
data-slot="tree-item"
data-value={@value}
tabindex="-1"
aria-expanded={@is_parent && (if @expanded, do: "true", else: "false")}
aria-selected={if @selected, do: "true", else: "false"}
aria-current={@aria_current}
aria-level={@level}
aria-posinset={@posinset}
aria-setsize={@setsize}
aria-disabled={@disabled && "true"}
class={@class}
{@rest}
>
<span
data-slot="tree-label"
class="flex cursor-pointer items-center gap-1.5 rounded-sm px-2 py-1.5 outline-none"
>
<svg
:if={@is_parent}
xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
data-slot="tree-chevron"
class="pointer-events-none size-4 shrink-0 text-muted-foreground transition-transform duration-200"
aria-hidden="true"
>
<polyline points="9 18 15 12 9 6" />
</svg>
<span :if={!@is_parent} aria-hidden="true" class="size-4 shrink-0"></span>
{@label}
</span>
{render_slot(@inner_block)}
</li>
"""
end
attr :class, :string, default: nil
slot :inner_block, required: true
def tree_group(assigns) do
~H"""
<ul role="group" data-slot="tree-group" class={["ml-3.5 border-l border-border pl-1.5", @class]}>
{render_slot(@inner_block)}
</ul>
"""
end
end
1. Save the file
Paste the markup; it relies only on the theme tokens in styles.css. Includes a boot + keyboard script — drop those if you already load site.js.
2. Use it
<ul role="tree" data-slot="tree" aria-label="File system" class="…">
<li role="treeitem" data-slot="tree-item" tabindex="-1" aria-expanded="true" aria-selected="false">
<span data-slot="tree-label" class="…"><svg data-slot="tree-chevron" …/> Projects</span>
<ul role="group" data-slot="tree-group" class="…">
<li role="treeitem" data-slot="tree-item" tabindex="-1" aria-selected="false">
<span data-slot="tree-label" class="…">project-1.docx</span>
</li>
</ul>
</li>
</ul>View source
<!--
shadcn-htmx — raw HTML tree (tree view) snippet.
Web-standard nested lists wired to the WAI-ARIA APG Tree View pattern:
<ul role="tree"> with <li role="treeitem"> nodes. Parent nodes carry
aria-expanded and hold a nested <ul role="group">; end nodes have no
aria-expanded. Collapsing is pure CSS: a closed parent hides its child group.
The inline boot script seeds the roving tabindex (first node tabindex="0").
For the full APG keyboard contract (arrows, Home/End, type-ahead, *, Enter)
copy the second <script> at the bottom — or use public/site.js, which wires
every [data-slot="tree"] on the page.
Optional treeitem attributes (see the other flavours' props):
- aria-current="page" — marks the node that represents the current
page/location, distinct from aria-selected. Shown on README.md below.
(APG treeview-navigation example.)
- aria-level / aria-posinset / aria-setsize — REQUIRED only for lazy-loaded
(htmx) trees whose children aren't yet in the DOM, e.g. a collapsed parent
that hx-get's its <ul role="group"> on expand. A fully rendered static
tree like this one can omit them; the browser computes them from the DOM.
(APG treeview pattern + MDN treeitem role; declared in treeview-1b.)
Example on a lazy parent:
<li role="treeitem" aria-expanded="false"
aria-level="1" aria-posinset="2" aria-setsize="3"
hx-get="/tree/reports/children" hx-target="next [role=group]" …>
-->
<ul role="tree" data-slot="tree" aria-label="File system"
class="w-fit min-w-56 select-none text-sm text-foreground">
<li role="treeitem" data-slot="tree-item" data-value="projects" tabindex="-1" aria-expanded="true" aria-selected="false" class="">
<span data-slot="tree-label" class="flex cursor-pointer items-center gap-1.5 rounded-sm px-2 py-1.5 outline-none">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
data-slot="tree-chevron" class="pointer-events-none size-4 shrink-0 text-muted-foreground transition-transform duration-200" aria-hidden="true">
<polyline points="9 18 15 12 9 6" />
</svg>
Projects
</span>
<ul role="group" data-slot="tree-group" class="ml-3.5 border-l border-border pl-1.5">
<li role="treeitem" data-slot="tree-item" data-value="p1" tabindex="-1" aria-selected="false" class="">
<span data-slot="tree-label" class="flex cursor-pointer items-center gap-1.5 rounded-sm px-2 py-1.5 outline-none">
<span aria-hidden="true" class="size-4 shrink-0"></span>
project-1.docx
</span>
</li>
<li role="treeitem" data-slot="tree-item" data-value="p2" tabindex="-1" aria-expanded="false" aria-selected="false" class="">
<span data-slot="tree-label" class="flex cursor-pointer items-center gap-1.5 rounded-sm px-2 py-1.5 outline-none">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
data-slot="tree-chevron" class="pointer-events-none size-4 shrink-0 text-muted-foreground transition-transform duration-200" aria-hidden="true">
<polyline points="9 18 15 12 9 6" />
</svg>
drafts
</span>
<ul role="group" data-slot="tree-group" class="ml-3.5 border-l border-border pl-1.5">
<li role="treeitem" data-slot="tree-item" data-value="d1" tabindex="-1" aria-selected="false" class="">
<span data-slot="tree-label" class="flex cursor-pointer items-center gap-1.5 rounded-sm px-2 py-1.5 outline-none">
<span aria-hidden="true" class="size-4 shrink-0"></span>
draft-a.docx
</span>
</li>
</ul>
</li>
</ul>
</li>
<!-- aria-current="page" marks this as the current-location node (distinct
from aria-selected). APG treeview-navigation example. -->
<li role="treeitem" data-slot="tree-item" data-value="readme" tabindex="-1" aria-selected="false" aria-current="page" class="">
<span data-slot="tree-label" class="flex cursor-pointer items-center gap-1.5 rounded-sm px-2 py-1.5 outline-none">
<span aria-hidden="true" class="size-4 shrink-0"></span>
README.md
</span>
</li>
</ul>
<!-- Boot: seed the roving tabindex (first node is the single tab stop). -->
<script>
document.querySelectorAll('[data-slot="tree"]:not([data-tree-ready])').forEach(function (tree) {
tree.querySelectorAll('[role="treeitem"]').forEach(function (it, i) {
it.setAttribute('tabindex', i === 0 ? '0' : '-1')
})
tree.setAttribute('data-tree-ready', 'true')
})
</script>
<!-- APG keyboard contract. Skip if you already load public/site.js. -->
<script>
(function () {
var visibleItems = function (tree) {
return [].slice.call(tree.querySelectorAll('[role="treeitem"]')).filter(function (it) {
var p = it.parentElement
while (p && p !== tree) {
if (p.getAttribute && p.getAttribute('role') === 'group') {
var owner = p.closest('[role="treeitem"]')
if (owner && owner.getAttribute('aria-expanded') === 'false') return false
}
p = p.parentElement
}
return true
})
}
var focusItem = function (tree, item) {
tree.querySelectorAll('[role="treeitem"]').forEach(function (it) {
it.setAttribute('tabindex', it === item ? '0' : '-1')
})
item.focus()
}
var childGroup = function (item) {
var el = item.firstElementChild
while (el) { if (el.getAttribute && el.getAttribute('role') === 'group') return el; el = el.nextElementSibling }
return null
}
document.addEventListener('keydown', function (e) {
var item = e.target.closest && e.target.closest('[role="treeitem"]')
if (!item) return
var tree = item.closest('[data-slot="tree"]')
if (!tree) return
if (item.getAttribute('aria-disabled') === 'true') return
var isParent = item.hasAttribute('aria-expanded')
var open = item.getAttribute('aria-expanded') === 'true'
var vis = visibleItems(tree)
var idx = vis.indexOf(item)
var key = e.key
if (key === 'ArrowDown') { e.preventDefault(); if (idx < vis.length - 1) focusItem(tree, vis[idx + 1]) }
else if (key === 'ArrowUp') { e.preventDefault(); if (idx > 0) focusItem(tree, vis[idx - 1]) }
else if (key === 'Home') { e.preventDefault(); focusItem(tree, vis[0]) }
else if (key === 'End') { e.preventDefault(); focusItem(tree, vis[vis.length - 1]) }
else if (key === 'ArrowRight') {
e.preventDefault()
if (isParent && !open) item.setAttribute('aria-expanded', 'true')
else if (isParent && open) { var g = childGroup(item); var f = g && g.querySelector('[role="treeitem"]'); if (f) focusItem(tree, f) }
} else if (key === 'ArrowLeft') {
e.preventDefault()
if (isParent && open) item.setAttribute('aria-expanded', 'false')
else { var pg = item.parentElement.closest('[role="treeitem"]'); if (pg) focusItem(tree, pg) }
} else if (key === 'Enter' || key === ' ') {
e.preventDefault()
tree.querySelectorAll('[role="treeitem"][aria-selected="true"]').forEach(function (n) { n.setAttribute('aria-selected', 'false') })
item.setAttribute('aria-selected', 'true')
if (isParent) item.setAttribute('aria-expanded', open ? 'false' : 'true')
} else if (key === '*') {
e.preventDefault()
var siblings = item.parentElement.children
for (var i = 0; i < siblings.length; i++) if (siblings[i].hasAttribute && siblings[i].hasAttribute('aria-expanded')) siblings[i].setAttribute('aria-expanded', 'true')
} else if (key.length === 1 && /\S/.test(key) && !e.ctrlKey && !e.metaKey && !e.altKey) {
var ch = key.toLowerCase()
for (var j = 1; j <= vis.length; j++) {
var cand = vis[(idx + j) % vis.length]
var lbl = cand.querySelector('[data-slot="tree-label"]')
if (((lbl ? lbl.textContent : cand.textContent) || '').trim().toLowerCase().indexOf(ch) === 0) { focusItem(tree, cand); break }
}
}
})
document.addEventListener('click', function (e) {
var label = e.target.closest && e.target.closest('[data-slot="tree-label"]')
if (!label) return
var item = label.closest('[role="treeitem"]')
var tree = item && item.closest('[data-slot="tree"]')
if (!tree || item.getAttribute('aria-disabled') === 'true') return
if (item.hasAttribute('aria-expanded')) item.setAttribute('aria-expanded', item.getAttribute('aria-expanded') === 'true' ? 'false' : 'true')
tree.querySelectorAll('[role="treeitem"][aria-selected="true"]').forEach(function (n) { n.setAttribute('aria-selected', 'false') })
item.setAttribute('aria-selected', 'true')
focusItem(tree, item)
})
})()
</script>
Examples
File tree — nested groups
Parent nodes get aria-expanded and contain a <ul role="group">. A closed parent hides its group via a pure-CSS attribute selector — no JS needed to render the collapsed state.
The structure is just nested <ul>/<li> lists with role="tree", role="treeitem", and role="group". Browsers compute aria-level, aria-setsize, and aria-posinset from this DOM nesting, so we don't declare them by hand.
- Projects
- project-1.docx
- project-2.docx
- drafts
- draft-a.docx
- draft-b.docx
- Reports
- q1.pdf
- q2.pdf
- README.md
<Tree ariaLabel="File system">
<TreeItem label="Projects" expanded>
<TreeGroup>
<TreeItem label="project-1.docx" />
<TreeItem label="drafts" parent>
<TreeGroup>
<TreeItem label="draft-a.docx" />
</TreeGroup>
</TreeItem>
</TreeGroup>
</TreeItem>
<TreeItem label="README.md" />
</Tree>{{ tree_open(aria_label="File system") }}
{{ tree_item_open(label="Projects", expanded=true) }}
{{ tree_group_open() }}
{{ tree_item_open(label="project-1.docx") }}{{ tree_item_close() }}
{{ tree_group_close() }}
{{ tree_item_close() }}
{{ tree_close() }}{{template "tree" (dict "AriaLabel" "File system" "Body" (htmlSafe `
{{template "tree_item" (dict "Label" "Projects" "Expanded" true "Body" (htmlSafe `…`))}}
`))}}<.tree aria_label="File system">
<.tree_item label="Projects" expanded>
<.tree_group><.tree_item label="project-1.docx" /></.tree_group>
</.tree_item>
</.tree><ul role="tree" data-slot="tree" aria-label="File system" class="w-fit min-w-56 select-none text-sm text-foreground">
<li role="treeitem" data-slot="tree-item" tabindex="-1" aria-expanded="true" aria-selected="false" class="">
<span data-slot="tree-label" class="flex cursor-pointer items-center gap-1.5 rounded-sm px-2 py-1.5 outline-none">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" data-slot="tree-chevron" class="pointer-events-none size-4 shrink-0 text-muted-foreground transition-transform duration-200" aria-hidden="true">
<polyline points="9 18 15 12 9 6">
</polyline>
</svg>
Projects
</span>
<ul role="group" data-slot="tree-group" class="ml-3.5 border-l border-border pl-1.5">
<li role="treeitem" data-slot="tree-item" tabindex="-1" aria-selected="false" class="">
<span data-slot="tree-label" class="flex cursor-pointer items-center gap-1.5 rounded-sm px-2 py-1.5 outline-none">
<span aria-hidden="true" class="size-4 shrink-0">
</span>
project-1.docx
</span>
</li>
<li role="treeitem" data-slot="tree-item" tabindex="-1" aria-selected="false" class="">
<span data-slot="tree-label" class="flex cursor-pointer items-center gap-1.5 rounded-sm px-2 py-1.5 outline-none">
<span aria-hidden="true" class="size-4 shrink-0">
</span>
project-2.docx
</span>
</li>
<li role="treeitem" data-slot="tree-item" tabindex="-1" aria-expanded="false" aria-selected="false" class="">
<span data-slot="tree-label" class="flex cursor-pointer items-center gap-1.5 rounded-sm px-2 py-1.5 outline-none">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" data-slot="tree-chevron" class="pointer-events-none size-4 shrink-0 text-muted-foreground transition-transform duration-200" aria-hidden="true">
<polyline points="9 18 15 12 9 6">
</polyline>
</svg>
drafts
</span>
<ul role="group" data-slot="tree-group" class="ml-3.5 border-l border-border pl-1.5">
<li role="treeitem" data-slot="tree-item" tabindex="-1" aria-selected="false" class="">
<span data-slot="tree-label" class="flex cursor-pointer items-center gap-1.5 rounded-sm px-2 py-1.5 outline-none">
<span aria-hidden="true" class="size-4 shrink-0">
</span>
draft-a.docx
</span>
</li>
<li role="treeitem" data-slot="tree-item" tabindex="-1" aria-selected="false" class="">
<span data-slot="tree-label" class="flex cursor-pointer items-center gap-1.5 rounded-sm px-2 py-1.5 outline-none">
<span aria-hidden="true" class="size-4 shrink-0">
</span>
draft-b.docx
</span>
</li>
</ul>
</li>
</ul>
</li>
<li role="treeitem" data-slot="tree-item" tabindex="-1" aria-expanded="false" aria-selected="false" class="">
<span data-slot="tree-label" class="flex cursor-pointer items-center gap-1.5 rounded-sm px-2 py-1.5 outline-none">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" data-slot="tree-chevron" class="pointer-events-none size-4 shrink-0 text-muted-foreground transition-transform duration-200" aria-hidden="true">
<polyline points="9 18 15 12 9 6">
</polyline>
</svg>
Reports
</span>
<ul role="group" data-slot="tree-group" class="ml-3.5 border-l border-border pl-1.5">
<li role="treeitem" data-slot="tree-item" tabindex="-1" aria-selected="false" class="">
<span data-slot="tree-label" class="flex cursor-pointer items-center gap-1.5 rounded-sm px-2 py-1.5 outline-none">
<span aria-hidden="true" class="size-4 shrink-0">
</span>
q1.pdf
</span>
</li>
<li role="treeitem" data-slot="tree-item" tabindex="-1" aria-selected="false" class="">
<span data-slot="tree-label" class="flex cursor-pointer items-center gap-1.5 rounded-sm px-2 py-1.5 outline-none">
<span aria-hidden="true" class="size-4 shrink-0">
</span>
q2.pdf
</span>
</li>
</ul>
</li>
<li role="treeitem" data-slot="tree-item" tabindex="-1" aria-selected="false" class="">
<span data-slot="tree-label" class="flex cursor-pointer items-center gap-1.5 rounded-sm px-2 py-1.5 outline-none">
<span aria-hidden="true" class="size-4 shrink-0">
</span>
README.md
</span>
</li>
</ul>
<script>
(function(el){
var items = el.querySelectorAll('[role="treeitem"]');
items.forEach(function(it,i){ it.setAttribute('tabindex', i===0?'0':'-1'); });
el.setAttribute('data-tree-ready','true');
})(document.currentScript.previousElementSibling);
</script>Further reading
Keyboard nav — arrows, Home/End, type-ahead
Tab lands on one node (roving tabindex). Down/Up move between visible nodes; Right expands then steps into the first child; Left collapses then steps to the parent; Home/End jump to first/last; * expands all siblings; typing a letter jumps to the next matching node.
This is the WAI-ARIA APG Tree View keyboard contract verbatim, wired in site.js on data-slot="tree". Only one node is in the page tab sequence (tabindex="0"); moving focus rolls that 0 onto the new node so Tab away and back returns where you left off. Because the handler is delegated, htmx-swapped subtrees work without re-init.
- Animals
- Birds
- Finch
- Falcon
- Mammals
- Otter
- Wolf
- Birds
- Plants
- Fern
- Moss
// Keyboard contract is handled by site.js — no extra props.
<Tree ariaLabel="Keyboard demo">…</Tree>{# keyboard nav is wired by site.js #}
{{ tree_open(aria_label="Keyboard demo") }}…{{ tree_close() }}{{template "tree" (dict "AriaLabel" "Keyboard demo" "Body" (htmlSafe `…`))}}<.tree aria_label="Keyboard demo">…</.tree><ul role="tree" data-slot="tree" aria-label="Keyboard demo" class="w-fit min-w-56 select-none text-sm text-foreground">
<li role="treeitem" data-slot="tree-item" tabindex="-1" aria-expanded="true" aria-selected="false" class="">
<span data-slot="tree-label" class="flex cursor-pointer items-center gap-1.5 rounded-sm px-2 py-1.5 outline-none">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" data-slot="tree-chevron" class="pointer-events-none size-4 shrink-0 text-muted-foreground transition-transform duration-200" aria-hidden="true">
<polyline points="9 18 15 12 9 6">
</polyline>
</svg>
Animals
</span>
<ul role="group" data-slot="tree-group" class="ml-3.5 border-l border-border pl-1.5">
<li role="treeitem" data-slot="tree-item" tabindex="-1" aria-expanded="false" aria-selected="false" class="">
<span data-slot="tree-label" class="flex cursor-pointer items-center gap-1.5 rounded-sm px-2 py-1.5 outline-none">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" data-slot="tree-chevron" class="pointer-events-none size-4 shrink-0 text-muted-foreground transition-transform duration-200" aria-hidden="true">
<polyline points="9 18 15 12 9 6">
</polyline>
</svg>
Birds
</span>
<ul role="group" data-slot="tree-group" class="ml-3.5 border-l border-border pl-1.5">
<li role="treeitem" data-slot="tree-item" tabindex="-1" aria-selected="false" class="">
<span data-slot="tree-label" class="flex cursor-pointer items-center gap-1.5 rounded-sm px-2 py-1.5 outline-none">
<span aria-hidden="true" class="size-4 shrink-0">
</span>
Finch
</span>
</li>
<li role="treeitem" data-slot="tree-item" tabindex="-1" aria-selected="false" class="">
<span data-slot="tree-label" class="flex cursor-pointer items-center gap-1.5 rounded-sm px-2 py-1.5 outline-none">
<span aria-hidden="true" class="size-4 shrink-0">
</span>
Falcon
</span>
</li>
</ul>
</li>
<li role="treeitem" data-slot="tree-item" tabindex="-1" aria-expanded="false" aria-selected="false" class="">
<span data-slot="tree-label" class="flex cursor-pointer items-center gap-1.5 rounded-sm px-2 py-1.5 outline-none">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" data-slot="tree-chevron" class="pointer-events-none size-4 shrink-0 text-muted-foreground transition-transform duration-200" aria-hidden="true">
<polyline points="9 18 15 12 9 6">
</polyline>
</svg>
Mammals
</span>
<ul role="group" data-slot="tree-group" class="ml-3.5 border-l border-border pl-1.5">
<li role="treeitem" data-slot="tree-item" tabindex="-1" aria-selected="false" class="">
<span data-slot="tree-label" class="flex cursor-pointer items-center gap-1.5 rounded-sm px-2 py-1.5 outline-none">
<span aria-hidden="true" class="size-4 shrink-0">
</span>
Otter
</span>
</li>
<li role="treeitem" data-slot="tree-item" tabindex="-1" aria-selected="false" class="">
<span data-slot="tree-label" class="flex cursor-pointer items-center gap-1.5 rounded-sm px-2 py-1.5 outline-none">
<span aria-hidden="true" class="size-4 shrink-0">
</span>
Wolf
</span>
</li>
</ul>
</li>
</ul>
</li>
<li role="treeitem" data-slot="tree-item" tabindex="-1" aria-expanded="false" aria-selected="false" class="">
<span data-slot="tree-label" class="flex cursor-pointer items-center gap-1.5 rounded-sm px-2 py-1.5 outline-none">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" data-slot="tree-chevron" class="pointer-events-none size-4 shrink-0 text-muted-foreground transition-transform duration-200" aria-hidden="true">
<polyline points="9 18 15 12 9 6">
</polyline>
</svg>
Plants
</span>
<ul role="group" data-slot="tree-group" class="ml-3.5 border-l border-border pl-1.5">
<li role="treeitem" data-slot="tree-item" tabindex="-1" aria-selected="false" class="">
<span data-slot="tree-label" class="flex cursor-pointer items-center gap-1.5 rounded-sm px-2 py-1.5 outline-none">
<span aria-hidden="true" class="size-4 shrink-0">
</span>
Fern
</span>
</li>
<li role="treeitem" data-slot="tree-item" tabindex="-1" aria-selected="false" class="">
<span data-slot="tree-label" class="flex cursor-pointer items-center gap-1.5 rounded-sm px-2 py-1.5 outline-none">
<span aria-hidden="true" class="size-4 shrink-0">
</span>
Moss
</span>
</li>
</ul>
</li>
</ul>
<script>
(function(el){
var items = el.querySelectorAll('[role="treeitem"]');
items.forEach(function(it,i){ it.setAttribute('tabindex', i===0?'0':'-1'); });
el.setAttribute('data-tree-ready','true');
})(document.currentScript.previousElementSibling);
</script>Further reading
Selection — aria-selected, with a disabled node
Single-select trees indicate the chosen node with aria-selected (the selected row gets the primary fill). Clicking or pressing Enter/Space selects. Disabled nodes set aria-disabled and are skipped by interaction.
Selection is distinct from focus — moving focus with the arrows does not change the selected node. Pass selected to pre-select the active node on render; the boot state is plain markup so a server (htmx/Phoenix) can drive it.
- src
- index.ts
- server.ts
- legacy.ts
- package.json
<Tree ariaLabel="Files">
<TreeItem label="src" expanded>
<TreeGroup>
<TreeItem label="index.ts" selected />
<TreeItem label="legacy.ts" disabled />
</TreeGroup>
</TreeItem>
</Tree>{{ tree_item_open(label="index.ts", selected=true) }}{{ tree_item_close() }}
{{ tree_item_open(label="legacy.ts", disabled=true) }}{{ tree_item_close() }}{{template "tree_item" (dict "Label" "index.ts" "Selected" true)}}
{{template "tree_item" (dict "Label" "legacy.ts" "Disabled" true)}}<.tree_item label="index.ts" selected />
<.tree_item label="legacy.ts" disabled /><ul role="tree" data-slot="tree" aria-label="Selection demo" class="w-fit min-w-56 select-none text-sm text-foreground">
<li role="treeitem" data-slot="tree-item" tabindex="-1" aria-expanded="true" aria-selected="false" class="">
<span data-slot="tree-label" class="flex cursor-pointer items-center gap-1.5 rounded-sm px-2 py-1.5 outline-none">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" data-slot="tree-chevron" class="pointer-events-none size-4 shrink-0 text-muted-foreground transition-transform duration-200" aria-hidden="true">
<polyline points="9 18 15 12 9 6">
</polyline>
</svg>
src
</span>
<ul role="group" data-slot="tree-group" class="ml-3.5 border-l border-border pl-1.5">
<li role="treeitem" data-slot="tree-item" tabindex="-1" aria-selected="true" class="">
<span data-slot="tree-label" class="flex cursor-pointer items-center gap-1.5 rounded-sm px-2 py-1.5 outline-none">
<span aria-hidden="true" class="size-4 shrink-0">
</span>
index.ts
</span>
</li>
<li role="treeitem" data-slot="tree-item" tabindex="-1" aria-selected="false" class="">
<span data-slot="tree-label" class="flex cursor-pointer items-center gap-1.5 rounded-sm px-2 py-1.5 outline-none">
<span aria-hidden="true" class="size-4 shrink-0">
</span>
server.ts
</span>
</li>
<li role="treeitem" data-slot="tree-item" tabindex="-1" aria-selected="false" aria-disabled="true" class="">
<span data-slot="tree-label" class="flex cursor-pointer items-center gap-1.5 rounded-sm px-2 py-1.5 outline-none">
<span aria-hidden="true" class="size-4 shrink-0">
</span>
legacy.ts
</span>
</li>
</ul>
</li>
<li role="treeitem" data-slot="tree-item" tabindex="-1" aria-selected="false" class="">
<span data-slot="tree-label" class="flex cursor-pointer items-center gap-1.5 rounded-sm px-2 py-1.5 outline-none">
<span aria-hidden="true" class="size-4 shrink-0">
</span>
package.json
</span>
</li>
</ul>
<script>
(function(el){
var items = el.querySelectorAll('[role="treeitem"]');
items.forEach(function(it,i){ it.setAttribute('tabindex', i===0?'0':'-1'); });
el.setAttribute('data-tree-ready','true');
})(document.currentScript.previousElementSibling);
</script>Further reading
API Reference
<Tree>
| Prop | Type | Default | Description |
|---|---|---|---|
<TreeItem> current | true|"page" | — | Marks the node that represents the current page/location, emitting aria-current ("page" for the boolean form). Distinct from selected: current is where you ARE, selected is what an action targets. Primary for trees used as htmx navigation.APGTree View navigation example (aria-current) |
<TreeItem> level | number | — | Emits aria-level (1-based depth). Required only for lazy-loaded (htmx) trees whose full node set is not yet in the DOM; static, fully rendered trees can omit it and let the browser compute level from the DOM.MDNtreeitem role (aria-level under dynamic loading) |
<TreeItem> posinset | number | — | Emits aria-posinset (1-based position among its siblings). Required only for lazy-loaded (htmx) trees where the sibling set is not fully present in the DOM; otherwise the browser computes it.MDNtreeitem role (aria-posinset under dynamic loading) |
<TreeItem> setsize | number | — | Emits aria-setsize (number of nodes in the sibling set). Required only for lazy-loaded (htmx) trees where siblings are not fully present in the DOM; otherwise the browser computes it.MDNtreeitem role (aria-setsize under dynamic loading) |
ariaLabel | string | — | Accessible name for the tree when there's no visible label. APG requires every tree to be labelled via aria-label or aria-labelledby.APGTree View roles, states & properties |
ariaLabelledby | string | — | Id of a visible element (e.g. a heading) that names the tree (alternative to ariaLabel).MDNaria-labelledby |
<TreeItem> label | Child | — | Visible label for a node. Rendered before any nested <TreeGroup> and used as the type-ahead match text. |
<TreeItem> expanded | boolean | — | Marks the node as a parent and sets its initial open state via aria-expanded. Pass it (true/false) on parent nodes; omit it on end nodes so they are not mis-announced as parents.APGaria-expanded on parent nodes |
<TreeItem> parent | boolean | false | Mark a node as a parent without pre-expanding it (forces aria-expanded="false"). Shorthand for expanded={false}. |
<TreeItem> selected | boolean | false | Single-select state — sets aria-selected and the primary fill. Selection is distinct from focus.MDNaria-selected |
<TreeItem> disabled | boolean | false | Sets aria-disabled and dims the node; click/keyboard interaction is skipped over it.MDNaria-disabled |
<TreeItem> value | string | — | Stable identifier emitted as data-value so consumers or htmx can target the node. |
class | string | — | Extra Tailwind classes appended to the root element. |
hx-* | any | — | Any htmx attribute. Forwarded onto the underlying element.htmxAttribute reference |