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.json2. Use it
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
/** @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
{% 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
{# 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
{{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
{{/*
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
<.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
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
<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
<!--
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.
| Subject | Summary | |
|---|---|---|
| Treegrids are awesome | Want to learn how to use them? | [email protected] |
| re: Treegrids are awesome | I agree, they are the shizzle | [email protected] |
| re: Treegrids are awesome | They are great for showing a lot of data | [email protected] |
| re: Treegrids are awesome | Cool, we needed an example and docs | [email protected] |
| re: Treegrids are awesome | I 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 [&: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 [&: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 [&: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 [&: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 [&: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>Further reading
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.
| Name | Size | Modified |
|---|---|---|
| src | — | 2 days ago |
| index.ts | 1.2 KB | 2 days ago |
| app.ts | 4.8 KB | yesterday |
| tests | — | last week |
| smoke.spec.ts | 3.1 KB | last week |
<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 [&: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 [&: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 [&: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 [&: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 [&: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>Further reading
API Reference
<Treegrid>
| Prop | Type | Default | Description |
|---|---|---|---|
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 |
ariaLabel | string | — | Accessible name when no visible <label>.MDNaria-label |
ariaLabelledby | string | — | 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 |
expanded | boolean | — | <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 |
hidden | boolean | — | <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 |
selected | boolean | — | <TreegridRow> only. Single-select treegrids set this on the selected row; emitted as aria-selected.MDNaria-selected |
first | boolean | — | <TreegridCell> only. Marks the first cell of a row; hosts the indent and (when expandable) the expand/collapse chevron. |
expandable | boolean | — | <TreegridCell> only. On the first cell of a PARENT row, renders the chevron whose rotation tracks the row's aria-expanded. |
class | string | — | Extra Tailwind classes appended to the root element. |
hx-* | any | — | Any htmx attribute. Forwarded onto the underlying element.htmxAttribute reference |
* required