Tree table
Hierarchical rows in a data table — a BOM explosion, account rollups,
org-unit budgets: expand and collapse branches while every row keeps
real table columns. A plain table with disclosure buttons, so screen
readers keep native table browse mode.
Not for hierarchy navigation — when the reader is
going somewhere (a cost-center to open, a category to drill into) and
the rows carry no data columns, this is the heavier tool: use
Tree, whose links and
disclosures are native elements with zero JS. Reach for the tree
table only when every row genuinely needs table columns beside its
branch.
Without the JS: the table renders exactly as the server
sent it — indentation comes from data-tree-level and the
open/closed state from aria-expanded plus
hidden, both markup. initTreeTable() adds only
the toggling, so a server-rendered tree is fully readable without it;
the disclosure buttons are what go inert.
Basic — a two-level BOM
| Component | Qty |
|---|---|
| Pump assembly | 1 |
| Impeller | 1 |
| Seal ring set | 2 |
| Mounting plate | 1 |
View code
<table class="bo-data-table bo-tree-table" data-tree-table>
<thead><tr><th scope="col">Component</th><th scope="col" class="bo-data-table__col--numeric">Qty</th></tr></thead>
<tbody>
<tr data-tree-level="1"><td>
<button class="bo-tree-table__toggle" type="button" aria-expanded="true" aria-label="Pump assembly"></button>Pump assembly</td>
<td class="bo-data-table__col--numeric">1</td></tr>
<tr data-tree-level="2"><td><span class="bo-tree-table__spacer"></span>Impeller</td>
<td class="bo-data-table__col--numeric">1</td></tr>
<tr data-tree-level="2"><td><span class="bo-tree-table__spacer"></span>Seal ring set</td>
<td class="bo-data-table__col--numeric">2</td></tr>
<tr data-tree-level="1"><td><span class="bo-tree-table__spacer"></span>Mounting plate</td>
<td class="bo-data-table__col--numeric">1</td></tr>
</tbody>
</table> Click the chevron: descendants hide via the
real hidden attribute, and aria-expanded
tracks it — chevron rotation is decoration, two channels carry the
state. Leaf rows use __spacer so the first column stays
aligned.
Nested branches — collapsed state is preserved
| Cost center | Budget |
|---|---|
| Operations | 120,000.00 |
| Warehouse | 80,000.00 |
| CC-1180 — Facilities | 56,000.00 |
| CC-2205 — Logistics | 24,000.00 |
| Front office | 40,000.00 |
View code
<!-- a branch rendered PRE-COLLAPSED: aria-expanded="false" on its
toggle + hidden on every descendant row, server-side -->
<table class="bo-data-table bo-tree-table" data-tree-table>
<thead><tr><th scope="col">Cost center</th><th scope="col" class="bo-data-table__col--numeric">Budget</th></tr></thead>
<tbody>
<tr data-tree-level="1"><td>
<button class="bo-tree-table__toggle" type="button" aria-expanded="true" aria-label="Operations"></button>Operations</td>
<td class="bo-data-table__col--numeric">120,000.00</td></tr>
<tr data-tree-level="2"><td>
<button class="bo-tree-table__toggle" type="button" aria-expanded="false" aria-label="Warehouse"></button>Warehouse</td>
<td class="bo-data-table__col--numeric">80,000.00</td></tr>
<tr data-tree-level="3" hidden><td><span class="bo-tree-table__spacer"></span>CC-1180 — Facilities</td>
<td class="bo-data-table__col--numeric">56,000.00</td></tr>
<tr data-tree-level="3" hidden><td><span class="bo-tree-table__spacer"></span>CC-2205 — Logistics</td>
<td class="bo-data-table__col--numeric">24,000.00</td></tr>
<tr data-tree-level="2"><td><span class="bo-tree-table__spacer"></span>Front office</td>
<td class="bo-data-table__col--numeric">40,000.00</td></tr>
</tbody>
</table> Warehouse renders pre-collapsed
(aria-expanded="false" + hidden children,
server-side). Collapse and re-expand Operations: Warehouse's own
children stay hidden until Warehouse itself is expanded — a folded
sub-branch never springs open by side effect.
When to use which — and why not treegrid
This component is deliberately a plain <table> +
disclosure buttons, not ARIA
role="treegrid": treegrid is the weakest-supported APG
composite, and adopting it forfeits native screen-reader table browse
mode — the wrong trade for read-mostly ERP hierarchy. The visible
channel is the indent; the programmatic channel is each toggle's
aria-expanded plus genuinely-hidden rows.
Programmatic level semantics (aria-level) are a
treegrid feature and are honestly not claimed — if a real adopter's
assistive-tech workflow needs them, that reopens the
ADR.
Totals note: data-sum-of
keeps counting collapsed rows — collapse is a view state, not a
filter.
Markup
<!-- A PLAIN table + disclosure buttons — deliberately NOT
role="treegrid" (native table browse mode beats the weakest-supported
APG composite for read-mostly hierarchy; full reasoning in the ADR).
Rows: data-tree-level="1..12". Parent rows: the toggle button, named
for its branch. Leaf rows: the spacer keeps columns aligned.
Hierarchy = document order (descendants are the following rows with
a higher level). -->
<table class="bo-data-table bo-tree-table" data-tree-table>
<tbody>
<tr data-tree-level="1"><td>
<button class="bo-tree-table__toggle" type="button"
aria-expanded="true" aria-label="Assembly A"></button>Assembly A</td></tr>
<tr data-tree-level="2"><td><span class="bo-tree-table__spacer"></span>Part A-1</td></tr>
</tbody>
</table>
<script type="module">
import { initTreeTable } from "@busy-office/ui/js";
initTreeTable();
</script> Quick reference generated
| Class | Role |
|---|---|
| .bo-tree-table | block (root) |
| .bo-tree-table__spacer | part: spacer |
| .bo-tree-table__toggle | part: toggle |
State attributes: data-tree-levelaria-expanded
API generated from CSS
| Blocks | bo-tree-table |
|---|---|
| Parts | bo-tree-table__spacerbo-tree-table__toggle |
| data-* hooks | data-tree-level="10|11|12|2|3|4|5|6|7|8|9" |
| ARIA styled | aria-expanded |
| JS required | initTreeTable() — delegated toggle handling: collapse hides ALL descendant rows; expand reveals direct children and only those deeper rows whose own parents are expanded (nested collapsed state preserved). Hierarchy is document order via data-tree-level. |
- Twelve indent levels ship (
data-tree-level="1..12") — real engineering BOMs run 8-12 deep; past that, compose your own padding rule. - Name each toggle for its BRANCH, without a verb (
aria-label="Pump assembly") —aria-expandedcarries the state, so a baked-in Collapse/Expand prefix would go stale the moment it toggles. - Keyboard: plain Tab reaches each toggle; Enter/Space activates — native buttons, nothing reimplemented.
- A toggle with no following deeper rows is INERT (the chevron never flips over nothing) — if a branch will not open, check its children have
data-tree-level. - Big hierarchies: render deep branches collapsed server-side and lazy-load on
bo:tree-toggle(fetch/htmx append child rows on first expand); a 10k-node BOM should not ship 10k rows up front. Expand-all / expand-to-level are app code over the same event — loop the toggles below level N and click the collapsed ones. - Render a pre-collapsed branch server-side:
aria-expanded="false"on the toggle +hiddenon every descendant row. - Composes with everything .bo-data-table has — numeric columns, badges, row states; it IS a data table.
Contrast — 12 AA pairs this component uses (generated, CI-gated)
| Text | on | Light | Dark |
|---|---|---|---|
| text-primary | bg-surface | 17.74 | 16.15 |
| text-primary | bg-canvas | 16.98 | 18.08 |
| text-secondary | bg-surface | 10.31 | 11.46 |
| text-secondary | bg-muted | 9.37 | 9.75 |
| bg-surface | text-primary | 17.74 | 16.15 |
| text-primary | bg-hover | 16.12 | 13.75 |
| text-primary | bg-selected | 17.01 | 11.86 |
| text-secondary | bg-selected | 9.88 | 8.41 |
| text-primary | bg-surface-raised | 17.74 | 14.51 |
| text-secondary | bg-canvas | 9.86 | 12.82 |
| border-control | bg-hover | 4.39 | 5.66 |
| text-primary | bg-muted | 16.12 | 13.75 |
Maturity generated
- Introduced
- v0.3.0
- CSS floor
- Chrome/Edge 99 · Firefox 97 · Safari 15.4
- Alignment scored
- 2026-08-23
- Assistive-tech evidence
- None recorded
Pin this component's first release with npm i @busy-office/ui@0.3.0.
The floor is derived from this component's stylesheet alone — set by
@layer — so it sits at or below the
whole framework's Chrome/Edge 119 · Firefox 129 · Safari 17.5.
No assistive-technology runtime result is recorded for any component: it needs a human listening to VoiceOver and NVDA on owner hardware; no gate in this repo can simulate announcement behaviour.
What automation covers instead is test:axe across every page at 1440 and 390, and check:forced-colors — see
accessibility.
Design-system alignment generated
100% (18/18) — how closely this component follows the design language. Each rule is scored 0-3 against a checkable citation; rules that don't apply leave the denominator. Scored 2026-08-23.
| Rule | Score | Evidence |
|---|---|---|
| Typography | 3 / 3 | no raw font-size |
| Colour | 3 / 3 | zero raw hex; the 1px ButtonText border is a forced-colors requirement, not a themed colour |
| Spacing | 3 / 3 | 1.5em disclosure box states why it is one line-box tall, and __spacer cites it (94.2) |
| Interaction | 3 / 3 | the opener states that indentation (data-tree-level) and open state (aria-expanded + hidden) are markup, so a server-rendered tree reads fine without the JS; initTreeTable() adds only the toggling (94.14) |
| Content | 3 / 3 | opener carries the wrong-choice clause: "Not for hierarchy navigation" — links with no data columns want tree's native zero-JS elements; earn this heavier tool only when every row needs columns beside its branch. Blind re-scored 2026-08-23 (Polish round 14); pair-coherence with tree verified. |
| Fit | 3 / 3 | hierarchical rows in a data table; the ADR names the context where the heavier composite would be wrong |