shshadcn-htmx

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.json

2. Use it

components/ui/tree.tsx
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
components/ui/tree.tsx
/** @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

templates/components/tree.html
{% 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
templates/components/tree.html
{# 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

components/tree.tmpl
{{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
components/tree.tmpl
{{/*
  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

lib/my_app_web/components/tree.ex
<.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
lib/my_app_web/components/tree.ex
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

snippets/tree.html
<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
snippets/tree.html
<!--
  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
  • 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>

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
// 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>

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>

API Reference

<Tree>

PropTypeDefaultDescription
<TreeItem> currenttrue|"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> levelnumber
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> posinsetnumber
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> setsizenumber
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)
ariaLabelstring
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
ariaLabelledbystring
Id of a visible element (e.g. a heading) that names the tree (alternative to ariaLabel).MDNaria-labelledby
<TreeItem> labelChild
Visible label for a node. Rendered before any nested <TreeGroup> and used as the type-ahead match text.
<TreeItem> expandedboolean
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> parentbooleanfalse
Mark a node as a parent without pre-expanding it (forces aria-expanded="false"). Shorthand for expanded={false}.
<TreeItem> selectedbooleanfalse
Single-select state — sets aria-selected and the primary fill. Selection is distinct from focus.MDNaria-selected
<TreeItem> disabledbooleanfalse
Sets aria-disabled and dims the node; click/keyboard interaction is skipped over it.MDNaria-disabled
<TreeItem> valuestring
Stable identifier emitted as data-value so consumers or htmx can target the node.
classstring
Extra Tailwind classes appended to the root element.
hx-*any
Any htmx attribute. Forwarded onto the underlying element.htmxAttribute reference