shshadcn-htmx

Components

Treegrid

A hierarchical data grid: role="treegrid" rows expand and collapse like a tree (aria-expanded / aria-level) while cells navigate like a grid with the arrow keys.

Installation

1. Install via the shadcn CLI

npx shadcn@latest add http://localhost/r/treegrid.json

2. Use it

components/ui/treegrid.tsx
import { Treegrid, TreegridRow, TreegridCell } from "@/components/ui/treegrid"

<Treegrid ariaLabel="Inbox" columns={["Subject", "Summary", "Email"]}>
  <TreegridRow level={1} posinset={1} setsize={1} expanded>
    <TreegridCell first level={1} expandable>Treegrids are awesome</TreegridCell>
    <TreegridCell>Want to learn how to use them?</TreegridCell>
    <TreegridCell><a href="mailto:[email protected]">[email protected]</a></TreegridCell>
  </TreegridRow>
  <TreegridRow level={2} posinset={1} setsize={1}>
    <TreegridCell first level={2}>re: Treegrids are awesome</TreegridCell>
    <TreegridCell>I agree</TreegridCell>
    <TreegridCell><a href="mailto:[email protected]">[email protected]</a></TreegridCell>
  </TreegridRow>
</Treegrid>
Or copy the source manually
components/ui/treegrid.tsx
/** @jsxImportSource hono/jsx */
import type { PropsWithChildren } from "hono/jsx"
import { cn, type ClassValue } from "@/registry/lib/cn"

// Treegrid — shadcn-htmx, htmx v4 + Tailwind v4.
//
// A treegrid is a hierarchical, 2-D-navigable grid: rows can be expanded /
// collapsed like a tree, and cells are navigable like a grid. shadcn/ui has
// no Treegrid, so there is no React source of truth to mirror — the contract
// here comes straight from the WAI-ARIA APG.
//
// Accessibility contract follows the APG Treegrid pattern + its example:
//   repos/aria-practices/content/patterns/treegrid/treegrid-pattern.html
//   repos/aria-practices/content/patterns/treegrid/examples/treegrid-1.html
//   repos/aria-practices/content/patterns/treegrid/examples/js/treegrid-1.js
//     (the keyboard model in public/site.js is a faithful port of this file —
//      the "rows focused first, but cells can be focused" variant, which is
//      doAllowRowFocus=true / doStartRowFocus=true in the example.)
//
// The contract, distilled from the pattern + example:
//   - Container is an HTML <table> with role="treegrid" and a label
//     (aria-label or aria-labelledby).
//   - The header is one <tr> of <th role="columnheader" scope="col">.
//   - Every body <tr> carries role="row" + aria-level (1-based),
//     aria-posinset and aria-setsize (position within its sibling set).
//   - A PARENT row (one with child rows) carries aria-expanded on the <tr>
//     itself ("rows focused first" variant). Rows with no children OMIT
//     aria-expanded, otherwise AT would announce them as empty parents.
//   - Cells are <td role="gridcell">.
//   - Collapsed descendant rows are hidden with the native `hidden`
//     attribute, which drops them from layout AND the accessibility tree
//     (repos/mdn/.../web/html/reference/global_attributes/hidden).
//   - Focus management is a roving tabindex over the rows; cells become
//     focusable on demand. An inline boot <script> sets the first visible
//     row to tabindex="0" before paint; public/site.js (data-slot="treegrid")
//     owns the live Up/Down/Left/Right/Home/End/Ctrl+Home/Ctrl+End/Enter
//     keyboard contract and the roving-tabindex bookkeeping.
//
// Refs:
//   repos/mdn/files/en-us/web/html/reference/elements/table/index.md
//   repos/mdn/files/en-us/web/html/reference/global_attributes/hidden/index.md
//   repos/mdn/files/en-us/web/accessibility/aria/reference/roles/treegrid_role
//
// Composition:
//   <Treegrid ariaLabel="Inbox" columns={["Subject", "Summary", "Email"]}>
//     <TreegridRow level={1} posinset={1} setsize={1} expanded>
//       <TreegridCell>Treegrids are awesome</TreegridCell>
//       <TreegridCell>Want to learn how to use them?</TreegridCell>
//       <TreegridCell><a href="mailto:[email protected]">[email protected]</a></TreegridCell>
//     </TreegridRow>
//     <TreegridRow level={2} posinset={1} setsize={2}>…</TreegridRow>
//   </Treegrid>

const containerBase = "relative w-full overflow-auto"

const tableBase = "w-full caption-bottom border-collapse text-sm"

const headRowBase = "border-b"

const headCellBase =
  "h-10 px-2 text-left align-middle font-medium text-muted-foreground"

const rowBase =
  "border-b outline-none transition-colors hover:bg-muted/50 " +
  // Roving-tabindex focus + selection styling. The row (or a cell inside it)
  // is the focus target; we light up both via :focus and :focus-within.
  "focus-visible:bg-accent focus-visible:text-accent-foreground " +
  "[&:focus-within]:bg-muted/60 " +
  "aria-selected:bg-muted data-[state=selected]:bg-muted"

const cellBase =
  "px-2 py-1.5 align-middle outline-none " +
  "focus-visible:bg-accent focus-visible:text-accent-foreground"

// First-cell wrapper holds the expand/collapse chevron + the indent. The
// chevron is an inline SVG rendered for parent rows only; its rotation is
// driven by the row's aria-expanded via a [data-slot="treegrid"] rule in
// app/styles/input.css (Tailwind utilities can't target an ancestor's ARIA
// state on a descendant pseudo).
const firstCellInnerBase = "flex items-center gap-1.5"

// A sortable column's sort state. Emitted as aria-sort on the header cell.
//   repos/mdn/.../web/accessibility/aria/reference/roles/treegrid_role
//   (".../treegrid sorting": aria-sort is on the header cell, not the grid).
export type TreegridSort = "none" | "ascending" | "descending"

// A column can be a plain label string (unchanged) or a descriptor. The
// descriptor lets a column carry aria-sort + htmx/data hooks so a sorted
// column can be marked and re-fetched on click — APG Treegrid pattern:
//   "If the treegrid provides sort functions, aria-sort is set to an
//    appropriate value on the header cell element for the sorted column."
//   repos/aria-practices/content/patterns/treegrid/treegrid-pattern.html
export type TreegridColumn =
  | string
  | ({
      label: string
      // Omit on non-sortable columns; set on the sorted column(s).
      sort?: TreegridSort
      class?: ClassValue
      // htmx / data attrs ride onto the header <th> (e.g. hx-get to refetch).
      [key: `hx-${string}`]: any
      [key: `data-${string}`]: any
      [key: `aria-${string}`]: any
    })

export type TreegridProps = PropsWithChildren<{
  // Column headers. Each is a plain label string, or a descriptor object
  // ({ label, sort?, ...hx }) to mark a sortable column. Rendered as
  // <th role="columnheader" scope="col">.
  columns: TreegridColumn[]
  // Required accessible name (APG: treegrid must be labelled).
  ariaLabel?: string
  ariaLabelledby?: string
  class?: ClassValue
  wrapperClass?: ClassValue
  id?: string
  // htmx / data / aria attributes ride onto the <table>.
  [key: `hx-${string}`]: any
  [key: `data-${string}`]: any
  [key: `aria-${string}`]: any
}>

export function Treegrid(props: TreegridProps) {
  const {
    columns,
    ariaLabel,
    ariaLabelledby,
    class: className,
    wrapperClass,
    children,
    ...rest
  } = props as any
  // Boot script: set the roving tabindex before paint so the treegrid is a
  // single tab stop immediately. The first body row gets tabindex="0", every
  // other row tabindex="-1"; any pre-focusable widgets inside rows are taken
  // out of the tab order (tabindex="-1") until their row becomes active.
  // Mirrors initAttributes() in the APG example's treegrid-1.js.
  const boot = `(function(el){
    var rows = el.querySelectorAll('tbody > tr[role="row"]');
    rows.forEach(function(r, i){ r.setAttribute('tabindex', i === 0 ? '0' : '-1'); });
    el.querySelectorAll('tbody a,tbody button,tbody input,tbody [tabindex]').forEach(function(f){
      if (f.getAttribute('role') === 'row') return;
      f.setAttribute('tabindex', '-1');
    });
    el.setAttribute('data-treegrid-ready', 'true');
  })(document.currentScript.previousElementSibling);`
  return (
    <div class={cn(containerBase, wrapperClass)}>
      <table
        role="treegrid"
        data-slot="treegrid"
        aria-label={ariaLabel}
        aria-labelledby={ariaLabelledby}
        class={cn(tableBase, className)}
        {...rest}
      >
        <thead>
          <tr role="row" class={headRowBase}>
            {(columns as TreegridColumn[]).map((col) => {
              // Plain string => non-sortable header, unchanged from before.
              if (typeof col === "string") {
                return (
                  <th role="columnheader" scope="col" class={headCellBase}>
                    {col}
                  </th>
                )
              }
              // Descriptor: emit aria-sort on the header cell when the column
              // is sortable, and forward any htmx/data/aria hooks. aria-sort
              // belongs on the header cell, not the grid (MDN treegrid role).
              const { label, sort, class: colClass, ...colRest } = col as any
              return (
                <th
                  role="columnheader"
                  scope="col"
                  aria-sort={sort}
                  class={cn(headCellBase, colClass)}
                  {...colRest}
                >
                  {label}
                </th>
              )
            })}
          </tr>
        </thead>
        <tbody>{children}</tbody>
      </table>
      <script
        // biome-ignore lint/security/noDangerouslySetInnerHtml: SSR boot
        dangerouslySetInnerHTML={{ __html: boot }}
      />
    </div>
  )
}

export type TreegridRowProps = PropsWithChildren<{
  // 1-based depth in the hierarchy. Root rows are level 1.
  level: number
  // 1-based position of this row within its sibling set.
  posinset: number
  // Total number of rows in this row's sibling set at this level.
  setsize: number
  // Parent rows only: true = children visible, false = collapsed. Omit on
  // leaf rows so AT doesn't announce them as empty parents.
  expanded?: boolean
  // Collapsed descendant rows pass hidden so they leave layout + the a11y
  // tree until their ancestor is expanded.
  hidden?: boolean
  // Single-select treegrids set this on the selected row.
  selected?: boolean
  class?: ClassValue
  [key: `hx-${string}`]: any
  [key: `data-${string}`]: any
  [key: `aria-${string}`]: any
}>

export function TreegridRow(props: TreegridRowProps) {
  const {
    level,
    posinset,
    setsize,
    expanded,
    hidden,
    selected,
    class: className,
    children,
    ...rest
  } = props as any
  return (
    <tr
      role="row"
      data-slot="treegrid-row"
      aria-level={level}
      aria-posinset={posinset}
      aria-setsize={setsize}
      aria-expanded={expanded === undefined ? undefined : expanded ? "true" : "false"}
      aria-selected={selected === undefined ? undefined : selected ? "true" : "false"}
      hidden={hidden ? true : undefined}
      class={cn(rowBase, className)}
      {...rest}
    >
      {children}
    </tr>
  )
}

export type TreegridCellProps = PropsWithChildren<{
  // The first cell of every row hosts the expand/collapse chevron + indent.
  // Set `first` on it and pass the row's `level` so the indent matches depth.
  first?: boolean
  level?: number
  // Mark the first cell of a PARENT row so the chevron renders. Mirrors the
  // row's aria-expanded; leaf rows leave this false/undefined.
  expandable?: boolean
  class?: ClassValue
  [key: `data-${string}`]: any
  [key: `aria-${string}`]: any
}>

export function TreegridCell(props: TreegridCellProps) {
  const {
    first,
    level = 1,
    expandable,
    class: className,
    children,
    ...rest
  } = props as any
  if (!first) {
    return (
      <td role="gridcell" data-slot="treegrid-cell" class={cn(cellBase, className)} {...rest}>
        {children}
      </td>
    )
  }
  // Indent the first cell by depth. 1rem per level keeps the hierarchy legible
  // without needing bespoke theme tokens; level 1 sits flush.
  const indent = (level - 1) * 1
  return (
    <td role="gridcell" data-slot="treegrid-cell" class={cn(cellBase, className)} {...rest}>
      <span class={firstCellInnerBase} style={`padding-left:${indent}rem`}>
        {expandable ? (
          <svg
            data-slot="treegrid-chevron"
            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"
            class="size-3.5 shrink-0 text-muted-foreground transition-transform"
            aria-hidden="true"
          >
            <polyline points="9 18 15 12 9 6" />
          </svg>
        ) : (
          <span class="size-3.5 shrink-0" aria-hidden="true" />
        )}
        <span>{children}</span>
      </span>
    </td>
  )
}

1. Save the file

Copy treegrid.html into templates/components/.

2. Use it

templates/components/treegrid.html
{% from "components/treegrid.html" import treegrid_open, treegrid_close,
   tg_row_open, tg_row_close, tg_cell, tg_first_cell %}

{{ treegrid_open(["Subject", "Summary", "Email"], aria_label="Inbox") }}
  {{ tg_row_open(level=1, posinset=1, setsize=1, expanded=true) }}
    {{ tg_first_cell("Treegrids are awesome", level=1, expandable=true) }}
    {{ tg_cell("Want to learn how to use them?") }}
    {{ tg_cell('<a href="mailto:[email protected]">[email protected]</a>') }}
  {{ tg_row_close() }}
{{ treegrid_close() }}
View source
templates/components/treegrid.html
{# Treegrid macros — shadcn-htmx, htmx v4 + Tailwind v4.
   Mirrors registry/ui/treegrid.tsx so a Python/Flask/FastAPI/Django project
   can render the same markup our docs site renders.

   A treegrid is a hierarchical, 2-D-navigable grid: rows expand/collapse like
   a tree, cells navigate like a grid. The accessibility contract comes from
   the WAI-ARIA APG Treegrid pattern (rows-focused-first variant):
     repos/aria-practices/content/patterns/treegrid/treegrid-pattern.html
     repos/aria-practices/content/patterns/treegrid/examples/treegrid-1.html

   Usage:
       {% from "components/treegrid.html" import treegrid_open, treegrid_close,
          tg_row_open, tg_row_close, tg_cell, tg_first_cell %}

       {{ treegrid_open(["Subject", "Summary", "Email"], aria_label="Inbox") }}
         {{ tg_row_open(level=1, posinset=1, setsize=1, expanded=true) }}
           {{ tg_first_cell("Treegrids are awesome", level=1, expandable=true) }}
           {{ tg_cell("Want to learn how to use them?") }}
           {{ tg_cell('<a href="mailto:[email protected]">[email protected]</a>') }}
         {{ tg_row_close() }}
       {{ treegrid_close() }}

   The keyboard contract (Up/Down/Left/Right/Home/End/Enter, roving tabindex)
   lives in public/site.js, keyed on data-slot="treegrid". #}

{% macro treegrid_open(columns, aria_label=none, aria_labelledby=none, id=none, wrapper_class="", extra_class="", **attrs) %}
<div class="relative w-full overflow-auto {{ wrapper_class }}">
  <table role="treegrid" data-slot="treegrid"
         {%- if id %} id="{{ id }}"{% endif %}
         {%- if aria_label %} aria-label="{{ aria_label }}"{% endif %}
         {%- if aria_labelledby %} aria-labelledby="{{ aria_labelledby }}"{% endif %}
         class="w-full caption-bottom border-collapse text-sm {{ extra_class }}"
         {%- for k, v in attrs.items() %} {{ k|replace('_', '-') }}="{{ v }}"{% endfor -%}
  >
    <thead>
      <tr role="row" class="border-b">
        {#- A column is either a plain label string (unchanged) or a mapping
            {label, sort?} so a sortable column can carry aria-sort on its
            header cell — APG Treegrid pattern: "If the treegrid provides sort
            functions, aria-sort is set ... on the header cell element for the
            sorted column."
            repos/aria-practices/content/patterns/treegrid/treegrid-pattern.html #}
        {%- for col in columns %}
        {%- if col is mapping %}
        <th role="columnheader" scope="col"
            {%- if col.sort %} aria-sort="{{ col.sort }}"{% endif %}
            class="h-10 px-2 text-left align-middle font-medium text-muted-foreground">{{ col.label }}</th>
        {%- else %}
        <th role="columnheader" scope="col" class="h-10 px-2 text-left align-middle font-medium text-muted-foreground">{{ col }}</th>
        {%- endif %}
        {%- endfor %}
      </tr>
    </thead>
    <tbody>
{% endmacro %}

{% macro treegrid_close() %}
    </tbody>
  </table>
  <script>(function(el){
    var rows = el.querySelectorAll('tbody > tr[role="row"]');
    rows.forEach(function(r, i){ r.setAttribute('tabindex', i === 0 ? '0' : '-1'); });
    el.querySelectorAll('tbody a,tbody button,tbody input,tbody [tabindex]').forEach(function(f){
      if (f.getAttribute('role') === 'row') return;
      f.setAttribute('tabindex', '-1');
    });
    el.setAttribute('data-treegrid-ready', 'true');
  })(document.currentScript.parentElement.querySelector('[data-slot="treegrid"]'));</script>
{% endmacro %}

{% macro tg_row_open(level, posinset, setsize, expanded=none, hidden=false, selected=none, extra_class="", **attrs) %}
<tr role="row" data-slot="treegrid-row"
    aria-level="{{ level }}" aria-posinset="{{ posinset }}" aria-setsize="{{ setsize }}"
    {%- if expanded is not none %} aria-expanded="{{ 'true' if expanded else 'false' }}"{% endif %}
    {%- if selected is not none %} aria-selected="{{ 'true' if selected else 'false' }}"{% endif %}
    {%- if hidden %} hidden{% endif %}
    class="border-b outline-none transition-colors hover:bg-muted/50 focus-visible:bg-accent focus-visible:text-accent-foreground [&:focus-within]:bg-muted/60 aria-selected:bg-muted data-[state=selected]:bg-muted {{ extra_class }}"
    {%- for k, v in attrs.items() %} {{ k|replace('_', '-') }}="{{ v }}"{% endfor -%}
>
{% endmacro %}

{% macro tg_row_close() %}
</tr>
{% endmacro %}

{% macro tg_cell(content, extra_class="", **attrs) %}
<td role="gridcell" data-slot="treegrid-cell"
    class="px-2 py-1.5 align-middle outline-none focus-visible:bg-accent focus-visible:text-accent-foreground {{ extra_class }}"
    {%- for k, v in attrs.items() %} {{ k|replace('_', '-') }}="{{ v }}"{% endfor -%}
>{{ content }}</td>
{% endmacro %}

{% macro tg_first_cell(content, level=1, expandable=false, extra_class="", **attrs) %}
<td role="gridcell" data-slot="treegrid-cell"
    class="px-2 py-1.5 align-middle outline-none focus-visible:bg-accent focus-visible:text-accent-foreground {{ extra_class }}"
    {%- for k, v in attrs.items() %} {{ k|replace('_', '-') }}="{{ v }}"{% endfor -%}
><span class="flex items-center gap-1.5" style="padding-left:{{ (level - 1) }}rem">
  {%- if expandable %}
  <svg data-slot="treegrid-chevron" 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" class="size-3.5 shrink-0 text-muted-foreground transition-transform" aria-hidden="true"><polyline points="9 18 15 12 9 6"/></svg>
  {%- else %}
  <span class="size-3.5 shrink-0" aria-hidden="true"></span>
  {%- endif %}
  <span>{{ content }}</span>
</span></td>
{% endmacro %}

1. Save the file

Add treegrid.tmpl alongside your other templates.

2. Use it

components/treegrid.tmpl
{{template "treegrid" (dict
  "AriaLabel" "Inbox"
  "Columns" (list "Subject" "Summary" "Email")
  "Body" (htmlSafe `
    {{template "treegrid_row" (dict "Level" 1 "Posinset" 1 "Setsize" 1
      "HasExpanded" true "Expanded" true "Body" (htmlSafe "…cells…"))}}
  `))}}
View source
components/treegrid.tmpl
{{/*
  Treegrid templates — shadcn-htmx, htmx v4 + Tailwind v4.
  Mirrors registry/ui/treegrid.tsx for Go projects using html/template.

  A treegrid is a hierarchical, 2-D-navigable grid: rows expand/collapse like
  a tree, cells navigate like a grid. The accessibility contract comes from
  the WAI-ARIA APG Treegrid pattern (rows-focused-first variant):
    repos/aria-practices/content/patterns/treegrid/treegrid-pattern.html
    repos/aria-practices/content/patterns/treegrid/examples/treegrid-1.html

  Usage:

      {{template "treegrid" (dict
        "AriaLabel" "Inbox"
        "Columns" (list "Subject" "Summary" "Email")
        "Body" (htmlSafe `
          <tr role="row" data-slot="treegrid-row" aria-level="1">…</tr>
        `))}}

  Build the rows with the row/cell helpers and concatenate, or render them
  with the "treegrid_row" / "treegrid_cell" / "treegrid_first_cell" templates:

      {{template "treegrid_row" (dict "Level" 1 "Posinset" 1 "Setsize" 1
         "Expanded" true "Body" (htmlSafe `…cells…`))}}

  The keyboard contract (Up/Down/Left/Right/Home/End/Enter, roving tabindex)
  lives in public/site.js, keyed on data-slot="treegrid".
*/}}

{{define "treegrid"}}
<div class="relative w-full overflow-auto{{if .WrapperClass}} {{.WrapperClass}}{{end}}">
  <table role="treegrid" data-slot="treegrid"
         {{- if .ID}} id="{{.ID}}"{{end}}
         {{- if .AriaLabel}} aria-label="{{.AriaLabel}}"{{end}}
         {{- if .AriaLabelledby}} aria-labelledby="{{.AriaLabelledby}}"{{end}}
         class="w-full caption-bottom border-collapse text-sm{{if .Class}} {{.Class}}{{end}}">
    <thead>
      <tr role="row" class="border-b">
        {{- /* A column is either a plain label string (unchanged) or a map
               (dict "Label" "Date" "Sort" "ascending") so a sortable column
               can carry aria-sort on its header cell — APG Treegrid pattern:
               "If the treegrid provides sort functions, aria-sort is set ...
               on the header cell element for the sorted column."
               repos/aria-practices/content/patterns/treegrid/treegrid-pattern.html */}}
        {{- range .Columns}}
        {{- if kindIs "map" .}}
        <th role="columnheader" scope="col"{{if .Sort}} aria-sort="{{.Sort}}"{{end}} class="h-10 px-2 text-left align-middle font-medium text-muted-foreground">{{.Label}}</th>
        {{- else}}
        <th role="columnheader" scope="col" class="h-10 px-2 text-left align-middle font-medium text-muted-foreground">{{.}}</th>
        {{- end}}
        {{- end}}
      </tr>
    </thead>
    <tbody>{{.Body}}</tbody>
  </table>
  <script>(function(el){
    var rows = el.querySelectorAll('tbody > tr[role="row"]');
    rows.forEach(function(r, i){ r.setAttribute('tabindex', i === 0 ? '0' : '-1'); });
    el.querySelectorAll('tbody a,tbody button,tbody input,tbody [tabindex]').forEach(function(f){
      if (f.getAttribute('role') === 'row') return;
      f.setAttribute('tabindex', '-1');
    });
    el.setAttribute('data-treegrid-ready', 'true');
  })(document.currentScript.parentElement.querySelector('[data-slot="treegrid"]'));</script>
</div>
{{end}}

{{define "treegrid_row"}}
{{- $level := or .Level 1 -}}
<tr role="row" data-slot="treegrid-row"
    aria-level="{{$level}}" aria-posinset="{{.Posinset}}" aria-setsize="{{.Setsize}}"
    {{- if .HasExpanded}} aria-expanded="{{if .Expanded}}true{{else}}false{{end}}"{{end}}
    {{- if .HasSelected}} aria-selected="{{if .Selected}}true{{else}}false{{end}}"{{end}}
    {{- if .Hidden}} hidden{{end}}
    class="border-b outline-none transition-colors hover:bg-muted/50 focus-visible:bg-accent focus-visible:text-accent-foreground [&:focus-within]:bg-muted/60 aria-selected:bg-muted data-[state=selected]:bg-muted{{if .Class}} {{.Class}}{{end}}">{{.Body}}</tr>
{{end}}

{{define "treegrid_cell"}}
<td role="gridcell" data-slot="treegrid-cell" class="px-2 py-1.5 align-middle outline-none focus-visible:bg-accent focus-visible:text-accent-foreground{{if .Class}} {{.Class}}{{end}}">{{.Body}}</td>
{{end}}

{{define "treegrid_first_cell"}}
{{- $level := or .Level 1 -}}
<td role="gridcell" data-slot="treegrid-cell" class="px-2 py-1.5 align-middle outline-none focus-visible:bg-accent focus-visible:text-accent-foreground{{if .Class}} {{.Class}}{{end}}"><span class="flex items-center gap-1.5" style="padding-left:{{sub $level 1}}rem">
  {{- if .Expandable}}
  <svg data-slot="treegrid-chevron" 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" class="size-3.5 shrink-0 text-muted-foreground transition-transform" aria-hidden="true"><polyline points="9 18 15 12 9 6"/></svg>
  {{- else}}
  <span class="size-3.5 shrink-0" aria-hidden="true"></span>
  {{- end}}
  <span>{{.Body}}</span>
</span></td>
{{end}}

{{/*
  Notes:
   - `dict`, `list`, and `sub` are sprig helpers; `htmlSafe` marks a string as
     template.HTML so nested rows/cells render as markup, not escaped text.
   - HasExpanded / HasSelected let you omit aria-expanded / aria-selected on
     leaf or non-selectable rows (APG: leaf rows must NOT carry aria-expanded).
*/}}

1. Save the file

Drop treegrid.ex into lib/my_app_web/components/.

2. Use it

lib/my_app_web/components/treegrid.ex
<.treegrid aria-label="Inbox" columns={["Subject", "Summary", "Email"]}>
  <.treegrid_row level={1} posinset={1} setsize={1} expanded={true}>
    <.treegrid_cell first level={1} expandable>Treegrids are awesome</.treegrid_cell>
    <.treegrid_cell>Want to learn how to use them?</.treegrid_cell>
    <.treegrid_cell><a href="mailto:[email protected]">[email protected]</a></.treegrid_cell>
  </.treegrid_row>
</.treegrid>
View source
lib/my_app_web/components/treegrid.ex
defmodule ShadcnHtmx.Components.Treegrid do
  @moduledoc """
  Treegrid — shadcn-htmx, htmx v4 + Tailwind v4 for Phoenix.

  Mirrors registry/ui/treegrid.tsx so a Phoenix LiveView project can render
  the same markup our docs site renders. htmx attributes pass through via
  `:rest`.

  A treegrid is a hierarchical, 2-D-navigable grid: rows expand/collapse like
  a tree, cells navigate like a grid. The accessibility contract comes from
  the WAI-ARIA APG Treegrid pattern (rows-focused-first variant):

    repos/aria-practices/content/patterns/treegrid/treegrid-pattern.html
    repos/aria-practices/content/patterns/treegrid/examples/treegrid-1.html

  ## Examples

      <.treegrid aria-label="Inbox" columns={["Subject", "Summary", "Email"]}>
        <.treegrid_row level={1} posinset={1} setsize={1} expanded={true}>
          <.treegrid_cell first level={1} expandable>Treegrids are awesome</.treegrid_cell>
          <.treegrid_cell>Want to learn how to use them?</.treegrid_cell>
          <.treegrid_cell><a href="mailto:[email protected]">[email protected]</a></.treegrid_cell>
        </.treegrid_row>
        <.treegrid_row level={2} posinset={1} setsize={1}>
          <.treegrid_cell first level={2}>re: Treegrids are awesome</.treegrid_cell>
          <.treegrid_cell>I agree</.treegrid_cell>
          <.treegrid_cell><a href="mailto:[email protected]">[email protected]</a></.treegrid_cell>
        </.treegrid_row>
      </.treegrid>

  The keyboard contract (Up/Down/Left/Right/Home/End/Enter, roving tabindex)
  lives in public/site.js, keyed on data-slot="treegrid".
  """

  use Phoenix.Component

  @table_base "w-full caption-bottom border-collapse text-sm"
  @head_cell "h-10 px-2 text-left align-middle font-medium text-muted-foreground"
  @row_base "border-b outline-none transition-colors hover:bg-muted/50 " <>
              "focus-visible:bg-accent focus-visible:text-accent-foreground " <>
              "[&:focus-within]:bg-muted/60 aria-selected:bg-muted data-[state=selected]:bg-muted"
  @cell_base "px-2 py-1.5 align-middle outline-none focus-visible:bg-accent focus-visible:text-accent-foreground"

  @boot """
  <script>(function(el){
    var rows = el.querySelectorAll('tbody > tr[role="row"]');
    rows.forEach(function(r, i){ r.setAttribute('tabindex', i === 0 ? '0' : '-1'); });
    el.querySelectorAll('tbody a,tbody button,tbody input,tbody [tabindex]').forEach(function(f){
      if (f.getAttribute('role') === 'row') return;
      f.setAttribute('tabindex', '-1');
    });
    el.setAttribute('data-treegrid-ready', 'true');
  })(document.currentScript.parentElement.querySelector('[data-slot="treegrid"]'));</script>
  """

  attr :columns, :list, required: true
  attr :class, :string, default: nil
  attr :wrapper_class, :string, default: nil
  attr :rest, :global, include: ~w(id aria-label aria-labelledby hx-get hx-post hx-target hx-swap)
  slot :inner_block, required: true

  def treegrid(assigns) do
    assigns =
      assigns
      |> assign(:boot, @boot)
      |> assign(:table_base, @table_base)
      |> assign(:head_cell, @head_cell)

    ~H"""
    <div class={["relative w-full overflow-auto", @wrapper_class]}>
      <table role="treegrid" data-slot="treegrid" class={[@table_base, @class]} {@rest}>
        <thead>
          <tr role="row" class="border-b">
            <%!--
              A column is either a plain label string (unchanged) or a map
              %{label: "Date", sort: "ascending"} so a sortable column can
              carry aria-sort on its header cell — APG Treegrid pattern: "If
              the treegrid provides sort functions, aria-sort is set ... on the
              header cell element for the sorted column."
              repos/aria-practices/content/patterns/treegrid/treegrid-pattern.html
            --%>
            <th :for={col <- @columns} role="columnheader" scope="col" aria-sort={col_sort(col)} class={@head_cell}>
              {col_label(col)}
            </th>
          </tr>
        </thead>
        <tbody>{render_slot(@inner_block)}</tbody>
      </table>
      {Phoenix.HTML.raw(@boot)}
    </div>
    """
  end

  # A column is a plain label string or a map with :label and optional :sort.
  defp col_label(col) when is_map(col), do: Map.get(col, :label) || Map.get(col, "label")
  defp col_label(col), do: col
  defp col_sort(col) when is_map(col), do: Map.get(col, :sort) || Map.get(col, "sort")
  defp col_sort(_col), do: nil

  attr :level, :integer, required: true
  attr :posinset, :integer, required: true
  attr :setsize, :integer, required: true
  attr :expanded, :any, default: nil
  attr :selected, :any, default: nil
  attr :hidden, :boolean, default: false
  attr :class, :string, default: nil
  attr :rest, :global, include: ~w(hx-get hx-post hx-target hx-swap)
  slot :inner_block, required: true

  def treegrid_row(assigns) do
    assigns = assign(assigns, :row_base, @row_base)

    ~H"""
    <tr
      role="row"
      data-slot="treegrid-row"
      aria-level={@level}
      aria-posinset={@posinset}
      aria-setsize={@setsize}
      aria-expanded={if is_nil(@expanded), do: nil, else: to_string(@expanded)}
      aria-selected={if is_nil(@selected), do: nil, else: to_string(@selected)}
      hidden={@hidden}
      class={[@row_base, @class]}
      {@rest}
    >
      {render_slot(@inner_block)}
    </tr>
    """
  end

  attr :first, :boolean, default: false
  attr :level, :integer, default: 1
  attr :expandable, :boolean, default: false
  attr :class, :string, default: nil
  attr :rest, :global
  slot :inner_block, required: true

  def treegrid_cell(assigns) do
    assigns = assign(assigns, :cell_base, @cell_base)

    ~H"""
    <td :if={!@first} role="gridcell" data-slot="treegrid-cell" class={[@cell_base, @class]} {@rest}>
      {render_slot(@inner_block)}
    </td>
    <td :if={@first} role="gridcell" data-slot="treegrid-cell" class={[@cell_base, @class]} {@rest}>
      <span class="flex items-center gap-1.5" style={"padding-left:#{@level - 1}rem"}>
        <svg
          :if={@expandable}
          data-slot="treegrid-chevron"
          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"
          class="size-3.5 shrink-0 text-muted-foreground transition-transform"
          aria-hidden="true"
        >
          <polyline points="9 18 15 12 9 6" />
        </svg>
        <span :if={!@expandable} class="size-3.5 shrink-0" aria-hidden="true"></span>
        <span>{render_slot(@inner_block)}</span>
      </span>
    </td>
    """
  end
end

1. Save the file

Paste the markup; it relies only on the theme tokens in styles.css plus the shared site.js keyboard contract.

2. Use it

snippets/treegrid.html
<table role="treegrid" data-slot="treegrid" aria-label="Inbox"
       class="w-full border-collapse text-sm">
  <thead><tr role="row">
    <th role="columnheader" scope="col">Subject</th> …
  </tr></thead>
  <tbody>
    <tr role="row" aria-level="1" aria-posinset="1" aria-setsize="1" aria-expanded="true">
      <td role="gridcell">Treegrids are awesome</td> …
    </tr>
    <tr role="row" aria-level="2" aria-posinset="1" aria-setsize="1">…</tr>
  </tbody>
</table>
<!-- inline boot <script> sets the roving tabindex; site.js owns the keys -->
View source
snippets/treegrid.html
<!--
  shadcn-htmx — raw HTML treegrid snippet.

  Mirrors registry/ui/treegrid.tsx. An HTML <table role="treegrid"> whose body
  rows expand/collapse like a tree (aria-expanded + aria-level on the <tr>) and
  whose cells navigate like a grid. Accessibility contract from the WAI-ARIA
  APG Treegrid pattern (rows-focused-first variant):
    repos/aria-practices/content/patterns/treegrid/treegrid-pattern.html

  The inline <script> right after the <table> sets the roving tabindex on first
  paint (first row tabindex="0", the rest -1; interactive widgets in rows go to
  -1 until their row is active). The live keyboard contract — Up/Down move by
  row or cell, Right expands a collapsed row then steps into cells, Left
  collapses / steps back to the row, Home/End/Ctrl+Home/Ctrl+End jump, Enter
  toggles — needs the wiring in public/site.js (keyed on data-slot="treegrid").

  Collapsed descendant rows carry the native `hidden` attribute, which removes
  them from layout AND the accessibility tree.

  Sortable columns: when a column header offers a sort function, set aria-sort
  ("ascending" | "descending" | "none") on that <th role="columnheader"> — the
  attribute belongs on the header cell, not the <table>. Hang hx-get on the
  same <th> to re-fetch the sorted data. APG Treegrid pattern:
    repos/aria-practices/content/patterns/treegrid/treegrid-pattern.html
    (e.g. <th role="columnheader" scope="col" aria-sort="ascending"
            hx-get="/inbox?sort=date">Date</th>)

  Required CSS theme variables: --muted, --muted-foreground, --accent,
  --accent-foreground, --border. See app/styles/input.css (plus the
  [data-slot="treegrid"] chevron rule there that rotates the expand icon).
-->

<div class="relative w-full overflow-auto">
  <table role="treegrid" data-slot="treegrid" aria-label="Inbox"
         class="w-full caption-bottom border-collapse text-sm">
    <thead>
      <tr role="row" class="border-b">
        <th role="columnheader" scope="col" class="h-10 px-2 text-left align-middle font-medium text-muted-foreground">Subject</th>
        <th role="columnheader" scope="col" class="h-10 px-2 text-left align-middle font-medium text-muted-foreground">Summary</th>
        <th role="columnheader" scope="col" class="h-10 px-2 text-left align-middle font-medium text-muted-foreground">Email</th>
      </tr>
    </thead>
    <tbody>
      <tr role="row" data-slot="treegrid-row" aria-level="1" aria-posinset="1" aria-setsize="1" aria-expanded="true"
          class="border-b outline-none transition-colors hover:bg-muted/50 focus-visible:bg-accent focus-visible:text-accent-foreground [&:focus-within]:bg-muted/60 aria-selected:bg-muted data-[state=selected]:bg-muted">
        <td role="gridcell" data-slot="treegrid-cell" class="px-2 py-1.5 align-middle outline-none focus-visible:bg-accent focus-visible:text-accent-foreground">
          <span class="flex items-center gap-1.5" style="padding-left:0rem">
            <svg data-slot="treegrid-chevron" 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" class="size-3.5 shrink-0 text-muted-foreground transition-transform" aria-hidden="true"><polyline points="9 18 15 12 9 6"/></svg>
            <span>Treegrids are awesome</span>
          </span>
        </td>
        <td role="gridcell" data-slot="treegrid-cell" class="px-2 py-1.5 align-middle outline-none focus-visible:bg-accent focus-visible:text-accent-foreground">Want to learn how to use them?</td>
        <td role="gridcell" data-slot="treegrid-cell" class="px-2 py-1.5 align-middle outline-none focus-visible:bg-accent focus-visible:text-accent-foreground"><a href="mailto:[email protected]" class="underline-offset-4 hover:underline">[email protected]</a></td>
      </tr>
      <tr role="row" data-slot="treegrid-row" aria-level="2" aria-posinset="1" aria-setsize="2"
          class="border-b outline-none transition-colors hover:bg-muted/50 focus-visible:bg-accent focus-visible:text-accent-foreground [&:focus-within]:bg-muted/60 aria-selected:bg-muted data-[state=selected]:bg-muted">
        <td role="gridcell" data-slot="treegrid-cell" class="px-2 py-1.5 align-middle outline-none focus-visible:bg-accent focus-visible:text-accent-foreground">
          <span class="flex items-center gap-1.5" style="padding-left:1rem">
            <span class="size-3.5 shrink-0" aria-hidden="true"></span>
            <span>re: Treegrids are awesome</span>
          </span>
        </td>
        <td role="gridcell" data-slot="treegrid-cell" class="px-2 py-1.5 align-middle outline-none focus-visible:bg-accent focus-visible:text-accent-foreground">I agree, they are the shizzle</td>
        <td role="gridcell" data-slot="treegrid-cell" class="px-2 py-1.5 align-middle outline-none focus-visible:bg-accent focus-visible:text-accent-foreground"><a href="mailto:[email protected]" class="underline-offset-4 hover:underline">[email protected]</a></td>
      </tr>
      <tr role="row" data-slot="treegrid-row" aria-level="2" aria-posinset="2" aria-setsize="2" aria-expanded="false"
          class="border-b outline-none transition-colors hover:bg-muted/50 focus-visible:bg-accent focus-visible:text-accent-foreground [&:focus-within]:bg-muted/60 aria-selected:bg-muted data-[state=selected]:bg-muted">
        <td role="gridcell" data-slot="treegrid-cell" class="px-2 py-1.5 align-middle outline-none focus-visible:bg-accent focus-visible:text-accent-foreground">
          <span class="flex items-center gap-1.5" style="padding-left:1rem">
            <svg data-slot="treegrid-chevron" 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" class="size-3.5 shrink-0 text-muted-foreground transition-transform" aria-hidden="true"><polyline points="9 18 15 12 9 6"/></svg>
            <span>re: Treegrids are awesome</span>
          </span>
        </td>
        <td role="gridcell" data-slot="treegrid-cell" class="px-2 py-1.5 align-middle outline-none focus-visible:bg-accent focus-visible:text-accent-foreground">They are great for showing a lot of data</td>
        <td role="gridcell" data-slot="treegrid-cell" class="px-2 py-1.5 align-middle outline-none focus-visible:bg-accent focus-visible:text-accent-foreground"><a href="mailto:[email protected]" class="underline-offset-4 hover:underline">[email protected]</a></td>
      </tr>
      <tr role="row" data-slot="treegrid-row" aria-level="3" aria-posinset="1" aria-setsize="1" hidden
          class="border-b outline-none transition-colors hover:bg-muted/50 focus-visible:bg-accent focus-visible:text-accent-foreground [&:focus-within]:bg-muted/60 aria-selected:bg-muted data-[state=selected]:bg-muted">
        <td role="gridcell" data-slot="treegrid-cell" class="px-2 py-1.5 align-middle outline-none focus-visible:bg-accent focus-visible:text-accent-foreground">
          <span class="flex items-center gap-1.5" style="padding-left:2rem">
            <span class="size-3.5 shrink-0" aria-hidden="true"></span>
            <span>re: Treegrids are awesome</span>
          </span>
        </td>
        <td role="gridcell" data-slot="treegrid-cell" class="px-2 py-1.5 align-middle outline-none focus-visible:bg-accent focus-visible:text-accent-foreground">Cool, we needed an example</td>
        <td role="gridcell" data-slot="treegrid-cell" class="px-2 py-1.5 align-middle outline-none focus-visible:bg-accent focus-visible:text-accent-foreground"><a href="mailto:[email protected]" class="underline-offset-4 hover:underline">[email protected]</a></td>
      </tr>
    </tbody>
  </table>
  <script>(function(el){
    var rows = el.querySelectorAll('tbody > tr[role="row"]');
    rows.forEach(function(r, i){ r.setAttribute('tabindex', i === 0 ? '0' : '-1'); });
    el.querySelectorAll('tbody a,tbody button,tbody input,tbody [tabindex]').forEach(function(f){
      if (f.getAttribute('role') === 'row') return;
      f.setAttribute('tabindex', '-1');
    });
    el.setAttribute('data-treegrid-ready', 'true');
  })(document.currentScript.parentElement.querySelector('[data-slot="treegrid"]'));</script>
</div>

Examples

Inbox — expand/collapse + grid navigation

Tab into the grid, then: Down/Up move by row, Right expands a collapsed row (or steps into the cells), Left collapses (or returns to the row), Enter toggles. Home/End and Ctrl+Home/Ctrl+End jump.

Rows carry aria-level, aria-posinset and aria-setsize. Only parent rows get aria-expanded — leaf rows omit it so AT never announces them as empty parents. Collapsed descendants use the native hidden attribute, so they leave both layout and the accessibility tree. Focus is a roving tabindex over the rows.

SubjectSummaryEmail
Treegrids are awesomeWant to learn how to use them?[email protected]
re: Treegrids are awesomeI agree, they are the shizzle[email protected]
re: Treegrids are awesomeI hear Fancytree is going to align with this![email protected]
import { Treegrid, TreegridRow, TreegridCell } from "@/components/ui/treegrid"

<Treegrid ariaLabel="Inbox" columns={["Subject", "Summary", "Email"]}>
  <TreegridRow level={1} posinset={1} setsize={1} expanded>
    <TreegridCell first level={1} expandable>Treegrids are awesome</TreegridCell>
    <TreegridCell>Want to learn how to use them?</TreegridCell>
    <TreegridCell><a href="mailto:[email protected]">[email protected]</a></TreegridCell>
  </TreegridRow>
  <TreegridRow level={2} posinset={1} setsize={1}>
    <TreegridCell first level={2}>re: Treegrids are awesome</TreegridCell>
    <TreegridCell>I agree</TreegridCell>
    <TreegridCell><a href="mailto:[email protected]">[email protected]</a></TreegridCell>
  </TreegridRow>
</Treegrid>
{% from "components/treegrid.html" import treegrid_open, treegrid_close,
   tg_row_open, tg_row_close, tg_cell, tg_first_cell %}

{{ treegrid_open(["Subject", "Summary", "Email"], aria_label="Inbox") }}
  {{ tg_row_open(level=1, posinset=1, setsize=1, expanded=true) }}
    {{ tg_first_cell("Treegrids are awesome", level=1, expandable=true) }}
    {{ tg_cell("Want to learn how to use them?") }}
    {{ tg_cell('<a href="mailto:[email protected]">[email protected]</a>') }}
  {{ tg_row_close() }}
{{ treegrid_close() }}
{{template "treegrid" (dict
  "AriaLabel" "Inbox"
  "Columns" (list "Subject" "Summary" "Email")
  "Body" (htmlSafe `
    {{template "treegrid_row" (dict "Level" 1 "Posinset" 1 "Setsize" 1
      "HasExpanded" true "Expanded" true "Body" (htmlSafe "…cells…"))}}
  `))}}
<.treegrid aria-label="Inbox" columns={["Subject", "Summary", "Email"]}>
  <.treegrid_row level={1} posinset={1} setsize={1} expanded={true}>
    <.treegrid_cell first level={1} expandable>Treegrids are awesome</.treegrid_cell>
    <.treegrid_cell>Want to learn how to use them?</.treegrid_cell>
    <.treegrid_cell><a href="mailto:[email protected]">[email protected]</a></.treegrid_cell>
  </.treegrid_row>
</.treegrid>
<div class="relative w-full overflow-auto">
  <table role="treegrid" data-slot="treegrid" aria-label="Inbox" class="w-full caption-bottom border-collapse text-sm">
    <thead>
      <tr role="row" class="border-b">
        <th role="columnheader" scope="col" class="h-10 px-2 text-left align-middle font-medium text-muted-foreground">Subject</th>
        <th role="columnheader" scope="col" class="h-10 px-2 text-left align-middle font-medium text-muted-foreground">Summary</th>
        <th role="columnheader" scope="col" class="h-10 px-2 text-left align-middle font-medium text-muted-foreground">Email</th>
      </tr>
    </thead>
    <tbody>
      <tr role="row" data-slot="treegrid-row" aria-level="1" aria-posinset="1" aria-setsize="1" aria-expanded="true" class="border-b outline-none transition-colors hover:bg-muted/50 focus-visible:bg-accent focus-visible:text-accent-foreground [&amp;:focus-within]:bg-muted/60 aria-selected:bg-muted data-[state=selected]:bg-muted">
        <td role="gridcell" data-slot="treegrid-cell" class="px-2 py-1.5 align-middle outline-none focus-visible:bg-accent focus-visible:text-accent-foreground">
          <span class="flex items-center gap-1.5" style="padding-left:0rem">
            <svg data-slot="treegrid-chevron" 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" class="size-3.5 shrink-0 text-muted-foreground transition-transform" aria-hidden="true">
              <polyline points="9 18 15 12 9 6">
              </polyline>
            </svg>
            <span>Treegrids are awesome</span>
          </span>
        </td>
        <td role="gridcell" data-slot="treegrid-cell" class="px-2 py-1.5 align-middle outline-none focus-visible:bg-accent focus-visible:text-accent-foreground">Want to learn how to use them?</td>
        <td role="gridcell" data-slot="treegrid-cell" class="px-2 py-1.5 align-middle outline-none focus-visible:bg-accent focus-visible:text-accent-foreground">
          <a href="mailto:[email protected]" class="underline-offset-4 hover:underline">[email protected]</a>
        </td>
      </tr>
      <tr role="row" data-slot="treegrid-row" aria-level="2" aria-posinset="1" aria-setsize="3" class="border-b outline-none transition-colors hover:bg-muted/50 focus-visible:bg-accent focus-visible:text-accent-foreground [&amp;:focus-within]:bg-muted/60 aria-selected:bg-muted data-[state=selected]:bg-muted">
        <td role="gridcell" data-slot="treegrid-cell" class="px-2 py-1.5 align-middle outline-none focus-visible:bg-accent focus-visible:text-accent-foreground">
          <span class="flex items-center gap-1.5" style="padding-left:1rem">
            <span class="size-3.5 shrink-0" aria-hidden="true">
            </span>
            <span>re: Treegrids are awesome</span>
          </span>
        </td>
        <td role="gridcell" data-slot="treegrid-cell" class="px-2 py-1.5 align-middle outline-none focus-visible:bg-accent focus-visible:text-accent-foreground">I agree, they are the shizzle</td>
        <td role="gridcell" data-slot="treegrid-cell" class="px-2 py-1.5 align-middle outline-none focus-visible:bg-accent focus-visible:text-accent-foreground">
          <a href="mailto:[email protected]" class="underline-offset-4 hover:underline">[email protected]</a>
        </td>
      </tr>
      <tr role="row" data-slot="treegrid-row" aria-level="2" aria-posinset="2" aria-setsize="3" aria-expanded="false" class="border-b outline-none transition-colors hover:bg-muted/50 focus-visible:bg-accent focus-visible:text-accent-foreground [&amp;:focus-within]:bg-muted/60 aria-selected:bg-muted data-[state=selected]:bg-muted">
        <td role="gridcell" data-slot="treegrid-cell" class="px-2 py-1.5 align-middle outline-none focus-visible:bg-accent focus-visible:text-accent-foreground">
          <span class="flex items-center gap-1.5" style="padding-left:1rem">
            <svg data-slot="treegrid-chevron" 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" class="size-3.5 shrink-0 text-muted-foreground transition-transform" aria-hidden="true">
              <polyline points="9 18 15 12 9 6">
              </polyline>
            </svg>
            <span>re: Treegrids are awesome</span>
          </span>
        </td>
        <td role="gridcell" data-slot="treegrid-cell" class="px-2 py-1.5 align-middle outline-none focus-visible:bg-accent focus-visible:text-accent-foreground">They are great for showing a lot of data</td>
        <td role="gridcell" data-slot="treegrid-cell" class="px-2 py-1.5 align-middle outline-none focus-visible:bg-accent focus-visible:text-accent-foreground">
          <a href="mailto:[email protected]" class="underline-offset-4 hover:underline">[email protected]</a>
        </td>
      </tr>
      <tr role="row" data-slot="treegrid-row" aria-level="3" aria-posinset="1" aria-setsize="1" hidden="" class="border-b outline-none transition-colors hover:bg-muted/50 focus-visible:bg-accent focus-visible:text-accent-foreground [&amp;:focus-within]:bg-muted/60 aria-selected:bg-muted data-[state=selected]:bg-muted">
        <td role="gridcell" data-slot="treegrid-cell" class="px-2 py-1.5 align-middle outline-none focus-visible:bg-accent focus-visible:text-accent-foreground">
          <span class="flex items-center gap-1.5" style="padding-left:2rem">
            <span class="size-3.5 shrink-0" aria-hidden="true">
            </span>
            <span>re: Treegrids are awesome</span>
          </span>
        </td>
        <td role="gridcell" data-slot="treegrid-cell" class="px-2 py-1.5 align-middle outline-none focus-visible:bg-accent focus-visible:text-accent-foreground">Cool, we needed an example and docs</td>
        <td role="gridcell" data-slot="treegrid-cell" class="px-2 py-1.5 align-middle outline-none focus-visible:bg-accent focus-visible:text-accent-foreground">
          <a href="mailto:[email protected]" class="underline-offset-4 hover:underline">[email protected]</a>
        </td>
      </tr>
      <tr role="row" data-slot="treegrid-row" aria-level="2" aria-posinset="3" aria-setsize="3" class="border-b outline-none transition-colors hover:bg-muted/50 focus-visible:bg-accent focus-visible:text-accent-foreground [&amp;:focus-within]:bg-muted/60 aria-selected:bg-muted data-[state=selected]:bg-muted">
        <td role="gridcell" data-slot="treegrid-cell" class="px-2 py-1.5 align-middle outline-none focus-visible:bg-accent focus-visible:text-accent-foreground">
          <span class="flex items-center gap-1.5" style="padding-left:1rem">
            <span class="size-3.5 shrink-0" aria-hidden="true">
            </span>
            <span>re: Treegrids are awesome</span>
          </span>
        </td>
        <td role="gridcell" data-slot="treegrid-cell" class="px-2 py-1.5 align-middle outline-none focus-visible:bg-accent focus-visible:text-accent-foreground">I hear Fancytree is going to align with this!</td>
        <td role="gridcell" data-slot="treegrid-cell" class="px-2 py-1.5 align-middle outline-none focus-visible:bg-accent focus-visible:text-accent-foreground">
          <a href="mailto:[email protected]" class="underline-offset-4 hover:underline">[email protected]</a>
        </td>
      </tr>
    </tbody>
  </table>
  <script>
    (function(el){
    var rows = el.querySelectorAll('tbody > tr[role="row"]');
    rows.forEach(function(r, i){ r.setAttribute('tabindex', i === 0 ? '0' : '-1'); });
    el.querySelectorAll('tbody a,tbody button,tbody input,tbody [tabindex]').forEach(function(f){
      if (f.getAttribute('role') === 'row') return;
      f.setAttribute('tabindex', '-1');
    });
    el.setAttribute('data-treegrid-ready', 'true');
  })(document.currentScript.previousElementSibling);
  </script>
</div>

File tree — multiple branches

Two top-level folders, one expanded and one collapsed. The chevron in the first cell only appears on rows that have children.

The same component renders any hierarchy. Each first cell is indented 1rem per level (from level), and the chevron renders only when expandable is set — matching the row's aria-expanded.

NameSizeModified
src2 days ago
index.ts1.2 KB2 days ago
app.ts4.8 KByesterday
<Treegrid ariaLabel="Project files" columns={["Name", "Size", "Modified"]}>
  <TreegridRow level={1} posinset={1} setsize={2} expanded>
    <TreegridCell first level={1} expandable>src</TreegridCell>
    <TreegridCell>—</TreegridCell>
    <TreegridCell>2 days ago</TreegridCell>
  </TreegridRow>
  <TreegridRow level={2} posinset={1} setsize={2}>
    <TreegridCell first level={2}>index.ts</TreegridCell>
    <TreegridCell>1.2 KB</TreegridCell>
    <TreegridCell>2 days ago</TreegridCell>
  </TreegridRow>
</Treegrid>
{{ treegrid_open(["Name", "Size", "Modified"], aria_label="Project files") }}
  {{ tg_row_open(level=1, posinset=1, setsize=2, expanded=true) }}
    {{ tg_first_cell("src", level=1, expandable=true) }}
    {{ tg_cell("—") }}{{ tg_cell("2 days ago") }}
  {{ tg_row_close() }}
{{ treegrid_close() }}
{{template "treegrid_row" (dict "Level" 1 "Posinset" 1 "Setsize" 2
  "HasExpanded" true "Expanded" true "Body" (htmlSafe `…first cell + cells…`))}}
<.treegrid aria-label="Project files" columns={["Name", "Size", "Modified"]}>
  <.treegrid_row level={1} posinset={1} setsize={2} expanded={true}>
    <.treegrid_cell first level={1} expandable>src</.treegrid_cell>
    <.treegrid_cell></.treegrid_cell>
    <.treegrid_cell>2 days ago</.treegrid_cell>
  </.treegrid_row>
</.treegrid>
<div class="relative w-full overflow-auto">
  <table role="treegrid" data-slot="treegrid" aria-label="Project files" class="w-full caption-bottom border-collapse text-sm">
    <thead>
      <tr role="row" class="border-b">
        <th role="columnheader" scope="col" class="h-10 px-2 text-left align-middle font-medium text-muted-foreground">Name</th>
        <th role="columnheader" scope="col" class="h-10 px-2 text-left align-middle font-medium text-muted-foreground">Size</th>
        <th role="columnheader" scope="col" class="h-10 px-2 text-left align-middle font-medium text-muted-foreground">Modified</th>
      </tr>
    </thead>
    <tbody>
      <tr role="row" data-slot="treegrid-row" aria-level="1" aria-posinset="1" aria-setsize="2" aria-expanded="true" class="border-b outline-none transition-colors hover:bg-muted/50 focus-visible:bg-accent focus-visible:text-accent-foreground [&amp;:focus-within]:bg-muted/60 aria-selected:bg-muted data-[state=selected]:bg-muted">
        <td role="gridcell" data-slot="treegrid-cell" class="px-2 py-1.5 align-middle outline-none focus-visible:bg-accent focus-visible:text-accent-foreground">
          <span class="flex items-center gap-1.5" style="padding-left:0rem">
            <svg data-slot="treegrid-chevron" 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" class="size-3.5 shrink-0 text-muted-foreground transition-transform" aria-hidden="true">
              <polyline points="9 18 15 12 9 6">
              </polyline>
            </svg>
            <span>src</span>
          </span>
        </td>
        <td role="gridcell" data-slot="treegrid-cell" class="px-2 py-1.5 align-middle outline-none focus-visible:bg-accent focus-visible:text-accent-foreground">—</td>
        <td role="gridcell" data-slot="treegrid-cell" class="px-2 py-1.5 align-middle outline-none focus-visible:bg-accent focus-visible:text-accent-foreground">2 days ago</td>
      </tr>
      <tr role="row" data-slot="treegrid-row" aria-level="2" aria-posinset="1" aria-setsize="2" class="border-b outline-none transition-colors hover:bg-muted/50 focus-visible:bg-accent focus-visible:text-accent-foreground [&amp;:focus-within]:bg-muted/60 aria-selected:bg-muted data-[state=selected]:bg-muted">
        <td role="gridcell" data-slot="treegrid-cell" class="px-2 py-1.5 align-middle outline-none focus-visible:bg-accent focus-visible:text-accent-foreground">
          <span class="flex items-center gap-1.5" style="padding-left:1rem">
            <span class="size-3.5 shrink-0" aria-hidden="true">
            </span>
            <span>index.ts</span>
          </span>
        </td>
        <td role="gridcell" data-slot="treegrid-cell" class="px-2 py-1.5 align-middle outline-none focus-visible:bg-accent focus-visible:text-accent-foreground">1.2 KB</td>
        <td role="gridcell" data-slot="treegrid-cell" class="px-2 py-1.5 align-middle outline-none focus-visible:bg-accent focus-visible:text-accent-foreground">2 days ago</td>
      </tr>
      <tr role="row" data-slot="treegrid-row" aria-level="2" aria-posinset="2" aria-setsize="2" class="border-b outline-none transition-colors hover:bg-muted/50 focus-visible:bg-accent focus-visible:text-accent-foreground [&amp;:focus-within]:bg-muted/60 aria-selected:bg-muted data-[state=selected]:bg-muted">
        <td role="gridcell" data-slot="treegrid-cell" class="px-2 py-1.5 align-middle outline-none focus-visible:bg-accent focus-visible:text-accent-foreground">
          <span class="flex items-center gap-1.5" style="padding-left:1rem">
            <span class="size-3.5 shrink-0" aria-hidden="true">
            </span>
            <span>app.ts</span>
          </span>
        </td>
        <td role="gridcell" data-slot="treegrid-cell" class="px-2 py-1.5 align-middle outline-none focus-visible:bg-accent focus-visible:text-accent-foreground">4.8 KB</td>
        <td role="gridcell" data-slot="treegrid-cell" class="px-2 py-1.5 align-middle outline-none focus-visible:bg-accent focus-visible:text-accent-foreground">yesterday</td>
      </tr>
      <tr role="row" data-slot="treegrid-row" aria-level="1" aria-posinset="2" aria-setsize="2" aria-expanded="false" class="border-b outline-none transition-colors hover:bg-muted/50 focus-visible:bg-accent focus-visible:text-accent-foreground [&amp;:focus-within]:bg-muted/60 aria-selected:bg-muted data-[state=selected]:bg-muted">
        <td role="gridcell" data-slot="treegrid-cell" class="px-2 py-1.5 align-middle outline-none focus-visible:bg-accent focus-visible:text-accent-foreground">
          <span class="flex items-center gap-1.5" style="padding-left:0rem">
            <svg data-slot="treegrid-chevron" 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" class="size-3.5 shrink-0 text-muted-foreground transition-transform" aria-hidden="true">
              <polyline points="9 18 15 12 9 6">
              </polyline>
            </svg>
            <span>tests</span>
          </span>
        </td>
        <td role="gridcell" data-slot="treegrid-cell" class="px-2 py-1.5 align-middle outline-none focus-visible:bg-accent focus-visible:text-accent-foreground">—</td>
        <td role="gridcell" data-slot="treegrid-cell" class="px-2 py-1.5 align-middle outline-none focus-visible:bg-accent focus-visible:text-accent-foreground">last week</td>
      </tr>
      <tr role="row" data-slot="treegrid-row" aria-level="2" aria-posinset="1" aria-setsize="1" hidden="" class="border-b outline-none transition-colors hover:bg-muted/50 focus-visible:bg-accent focus-visible:text-accent-foreground [&amp;:focus-within]:bg-muted/60 aria-selected:bg-muted data-[state=selected]:bg-muted">
        <td role="gridcell" data-slot="treegrid-cell" class="px-2 py-1.5 align-middle outline-none focus-visible:bg-accent focus-visible:text-accent-foreground">
          <span class="flex items-center gap-1.5" style="padding-left:1rem">
            <span class="size-3.5 shrink-0" aria-hidden="true">
            </span>
            <span>smoke.spec.ts</span>
          </span>
        </td>
        <td role="gridcell" data-slot="treegrid-cell" class="px-2 py-1.5 align-middle outline-none focus-visible:bg-accent focus-visible:text-accent-foreground">3.1 KB</td>
        <td role="gridcell" data-slot="treegrid-cell" class="px-2 py-1.5 align-middle outline-none focus-visible:bg-accent focus-visible:text-accent-foreground">last week</td>
      </tr>
    </tbody>
  </table>
  <script>
    (function(el){
    var rows = el.querySelectorAll('tbody > tr[role="row"]');
    rows.forEach(function(r, i){ r.setAttribute('tabindex', i === 0 ? '0' : '-1'); });
    el.querySelectorAll('tbody a,tbody button,tbody input,tbody [tabindex]').forEach(function(f){
      if (f.getAttribute('role') === 'row') return;
      f.setAttribute('tabindex', '-1');
    });
    el.setAttribute('data-treegrid-ready', 'true');
  })(document.currentScript.previousElementSibling);
  </script>
</div>

API Reference

<Treegrid>

PropTypeDefaultDescription
columns(string | { label: string; sort?: "none" | "ascending" | "descending"; class?: string })[]
<Treegrid> only. Column headers. Each entry is a plain label string, or a descriptor object to mark a sortable column. A descriptor's sort is emitted as aria-sort on that <th role="columnheader"> (per APG, aria-sort lives on the header cell, not the grid); any hx-*/data-*/aria-* keys on the descriptor forward onto the header so the sorted column can be re-fetched via htmx. Plain-string columns render exactly as before with no aria-sort.APGTreegrid roles, states & properties (aria-sort)
columns*string[]
Column header labels for <Treegrid>. Rendered as a single header row of <th role="columnheader" scope="col">.APGTreegrid roles, states & properties
ariaLabelstring
Accessible name when no visible <label>.MDNaria-label
ariaLabelledbystring
Id of a visible element providing the accessible name.MDNaria-labelledby
level*number
<TreegridRow> only. 1-based depth in the hierarchy; root rows are level 1. Emitted as aria-level.MDNaria-level
posinset*number
<TreegridRow> only. 1-based position of this row within its sibling set at this level. Emitted as aria-posinset.MDNaria-posinset
setsize*number
<TreegridRow> only. Total number of rows in this row's sibling set at this level. Emitted as aria-setsize.MDNaria-setsize
expandedboolean
<TreegridRow> only. Set on PARENT rows: true = children visible, false = collapsed. Emitted as aria-expanded on the <tr>. Omit on leaf rows so AT doesn't announce them as empty parents.APGTreegrid pattern
hiddenboolean
<TreegridRow> only. Collapsed descendant rows pass hidden to leave layout AND the accessibility tree until their ancestor is expanded. The keyboard contract toggles this attribute.MDNhidden attribute
selectedboolean
<TreegridRow> only. Single-select treegrids set this on the selected row; emitted as aria-selected.MDNaria-selected
firstboolean
<TreegridCell> only. Marks the first cell of a row; hosts the indent and (when expandable) the expand/collapse chevron.
expandableboolean
<TreegridCell> only. On the first cell of a PARENT row, renders the chevron whose rotation tracks the row's aria-expanded.
classstring
Extra Tailwind classes appended to the root element.
hx-*any
Any htmx attribute. Forwarded onto the underlying element.htmxAttribute reference

* required