Tree

ul.tree[role="list"]

Tree — a nested, expandable hierarchy: a file browser, a documentation table of contents with sub-pages, a category picker.

Examples

A file tree

View “A file tree” as
<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.

Level 2 tokens declared by tree
TokenDefault
--tree-branch-colorvar(--color-on-surface)
--tree-branch-font-weightvar(--font-weight-medium)
--tree-current-bgvar(--color-surface-sunken)
--tree-current-colorvar(--color-on-surface)
--tree-current-font-weightvar(--font-weight-semibold)
--tree-leaf-colorvar(--color-on-surface-muted)
--tree-leaf-font-weightvar(--font-weight-normal)
--tree-marker-colorvar(--color-on-surface-subtle)
--tree-marker-size0.5em
--tree-row-gapvar(--space-2)
--tree-row-hover-bgvar(--color-surface-sunken)
--tree-row-min-size2rem
--tree-row-padding-blockvar(--space-1)
--tree-row-padding-inlinevar(--space-2)
--tree-row-radiusvar(--radius-field)
--tree-guide-colorvar(--color-border-subtle)
--tree-guide-offsetcalc( var(--tree-row-padding-inline) + var(--tree-marker-size) / 2 )
--tree-guide-width1px
--tree-indentvar(--space-4)
--tree-colorvar(--color-on-surface)
--tree-font-familyvar(--font-secondary)
--tree-font-sizevar(--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

  1. Put role="list" on .tree and 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.
  2. 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.
  3. Put .tree-leaf on 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.
  4. Mark the current row with aria-current="page", and mark exactly one. Open its ancestors with open — nothing here reveals a row on its own.
  5. Do not add aria-expanded to 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