Tree
Tree — a nested, expandable hierarchy: a file browser, a documentation table of contents with sub-pages, a category picker.
Examples
<ul class="tree" role="list">
<li>
<details class="tree-branch" open>
<summary class="tree-summary">src</summary>
<ul class="tree-group" role="list">
<li>
<details class="tree-branch">
<summary class="tree-summary">components</summary>
<ul class="tree-group" role="list">
<li><a class="tree-leaf" href="#button">button.css</a></li>
<li><a class="tree-leaf" href="#nav">nav.css</a></li>
</ul>
</details>
</li>
<li><a class="tree-leaf" href="#index">index.ts</a></li>
</ul>
</details>
</li>
<li><a class="tree-leaf" href="#readme">README.md</a></li>
</ul>Nested <details>, not role="tree". That role promises arrow-key traversal, typeahead and a single tab stop; shipping it without the script would send a screen reader user down a path that dead-ends.
What you give up is the "tree item, level 3, 2 of 7" announcement.
Tokens 22
Level 2, declared on .tree itself. Set any of them on that selector to restyle this component without touching the skin.
| Token | Default |
|---|---|
| --tree-branch-color | var(--color-on-surface) |
| --tree-branch-font-weight | var(--font-weight-medium) |
| --tree-current-bg | var(--color-surface-sunken) |
| --tree-current-color | var(--color-on-surface) |
| --tree-current-font-weight | var(--font-weight-semibold) |
| --tree-leaf-color | var(--color-on-surface-muted) |
| --tree-leaf-font-weight | var(--font-weight-normal) |
| --tree-marker-color | var(--color-on-surface-subtle) |
| --tree-marker-size | 0.5em |
| --tree-row-gap | var(--space-2) |
| --tree-row-hover-bg | var(--color-surface-sunken) |
| --tree-row-min-size | 2rem |
| --tree-row-padding-block | var(--space-1) |
| --tree-row-padding-inline | var(--space-2) |
| --tree-row-radius | var(--radius-field) |
| --tree-guide-color | var(--color-border-subtle) |
| --tree-guide-offset | calc( var(--tree-row-padding-inline) + var(--tree-marker-size) / 2 ) |
| --tree-guide-width | 1px |
| --tree-indent | var(--space-4) |
| --tree-color | var(--color-on-surface) |
| --tree-font-family | var(--font-secondary) |
| --tree-font-size | var(--font-size-sm) |
Variants and states
Variants
None. It is one shape, and the page does the rest.
Inside it
.tree-leaf.tree-summary.tree-branch.tree-group
State it reads
Read from the platform, never mirrored into a class that could disagree with it.
:disabled:focus:hover[open]
Before you ship it
What you have to do 5 requirements
- Put
role="list"on.treeand on every.tree-group. The reset strips markers from classed lists, which is what stops VoiceOver announcing "list, N items", and the nesting of those lists is the only thing left conveying depth here. - Keep the shape:
<li>holds either a<details class="tree-branch">or a<a class="tree-leaf">, never both, and the nested<ul class="tree-group">goes inside the<details>— outside it, the children never hide. - Put
.tree-leafon the<a>itself. The reset only clears UA link styling for anchors that carry a class, so a tree styled through a descendant selector comes back blue and underlined. - Mark the current row with
aria-current="page", and mark exactly one. Open its ancestors withopen— nothing here reveals a row on its own. - Do not add
aria-expandedto the summary. No engine sets one on<summary>and none is needed: the UA exposes the disclosure's state itself, and a hand-written attribute is one more thing that can go stale.
src/css/components/tree.css · npx mostlycss add tree