Data Display

Treegrid

Available

A grid with expandable and collapsible rows. A composite of tree and grid patterns.

What Is a Treegrid?

A treegrid is "a grid (table) where rows can be expanded and collapsed."Email thread lists, file/folder listings, and comment trees are common use cases — whenever you need to handle both "tabular data" and "hierarchical (parent → child) expansion."

Think of it as a regular table (table) with the added ability to collapse rows. Because of this, you need to convey both the table structure (rows, cells, headers) and tree state (expanded / collapsed) to assistive technologies.

Why Does Accessibility Matter?

Building it with visual styling alone leaves these people behind:

The solution is to convey structure with role="treegrid" / role="row" / role="gridcell", add aria-expanded to parent rows to convey expand/collapse state, and enable arrow key navigation.

Live Demo (Recommended Implementation)

Below is an email thread list implemented according to the APG. Try operating it without the mouse, using only the keyboard.

Accessible Treegrid (Inbox)
FromSubjectDate
YamadaYear-end party (3 messages)12/10
YamadaRe: Scheduling discussion12/10
SatoRe: Restaurant booked12/11

Try it: Tab into the grid → ↑ ↓ to navigate rows → → to expand a parent row / ← to collapse → Home / End to jump to first / last row.

Tip

Turn on a screen reader (on macOS, press +F5 to toggle VoiceOver) and you'll hear parent rows announced as "expanded / collapsed" and "level 1, 1 of 2" —expand/collapse state plus hierarchy and position. This is the effect of aria-expanded and aria-level / aria-posinset / aria-setsize.

Keyboard Interaction

KeyActionPriority
TabEnter / leave the grid (only one tab stop inside)Required
/ Move focus to the next / previous visible rowRequired
Expand a collapsed parent row. If already expanded or a leaf, move to the first cell. On a cell, move rightRequired
Collapse an expanded parent row. No action on a collapsed row. On a cell, move left (from the first cell, move to the row)Required
Home / EndMove to the first / last visible rowRecommended

Note

The treegrid acts as a single tab stop. Pressing Tab repeatedly won't stop at each row. Once inside, use arrow keys to navigate rows. This is achieved with roving tabindex(only the currently selected row has tabindex="0", others have -1).

Required WAI-ARIA Roles & Properties

TargetAttribute / RoleMeaning
Outer containerrole="treegrid" + aria-labelConveys that it is a collapsible table. Also provide a label (name).
Each rowrole="row"Identifies a single row in the table.
Header cellrole="columnheader"Column header. Associates each cell with its column.
Data cellrole="gridcell"A single cell within a row.
Expandable parent rowaria-expanded="true | false"Whether the row is expanded. Must be updated on toggle.
Each rowaria-level / aria-posinset / aria-setsizeDepth level and position among siblings (optional but recommended).
Row (focus management)tabindex="0 | -1"Roving tabindex. Only the current row gets 0; all others get -1.
Collapsed child rowshiddenWhile collapsed, hide from the DOM and exclude from keyboard and screen reader access.

Recommended Pattern (Good)

Good / Recommended

Layer role="treegrid" roles on top of a native <table>, and synchronize parent row aria-expanded and roving tabindex with JavaScript.

Markup:

<table role="treegrid" aria-label="Inbox">
  <thead>
    <tr role="row">
      <th role="columnheader">From</th>
      <th role="columnheader">Subject</th>
      <th role="columnheader">Date</th>
    </tr>
  </thead>
  <tbody>
    <!-- Parent row: expandable. aria-expanded conveys open/close state -->
    <tr role="row"
        aria-level="1" aria-posinset="1" aria-setsize="2"
        aria-expanded="true" tabindex="0">
      <td role="gridcell">Yamada</td>
      <td role="gridcell">Year-end party (3 messages)</td>
      <td role="gridcell">12/10</td>
    </tr>
    <!-- Child rows: hidden while parent is collapsed -->
    <tr role="row" aria-level="2" aria-posinset="1" aria-setsize="2" tabindex="-1">
      <td role="gridcell">Yamada</td>
      <td role="gridcell">Re: Scheduling discussion</td>
      <td role="gridcell">12/10</td>
    </tr>
    <tr role="row" aria-level="2" aria-posinset="2" aria-setsize="2" tabindex="-1">
      <td role="gridcell">Sato</td>
      <td role="gridcell">Re: Restaurant booked</td>
      <td role="gridcell">12/11</td>
    </tr>
  </tbody>
</table>

Keyboard interaction and state synchronization script:

const grid = document.querySelector('[role="treegrid"]');
const rows = Array.from(grid.querySelectorAll('tbody [role="row"]'));

// Collect only currently operable (visible) rows
const visibleRows = () => rows.filter((r) => !r.hidden);

// Roving tabindex: only one row has tabindex="0", the rest are "-1"
function focusRow(row) {
  rows.forEach((r) => r.setAttribute('tabindex', r === row ? '0' : '-1'));
  row.focus();
}

// Toggle child rows (one level deeper) following a parent row
function setExpanded(row, open) {
  row.setAttribute('aria-expanded', String(open));
  let next = row.nextElementSibling;
  while (next && next.getAttribute('aria-level') === '2') {
    next.hidden = !open;        // Hidden rows are excluded from keyboard/screen reader
    next = next.nextElementSibling;
  }
}

grid.addEventListener('keydown', (e) => {
  const row = e.target.closest('[role="row"]');
  if (!row) return;
  const list = visibleRows();
  const i = list.indexOf(row);
  const expandable = row.hasAttribute('aria-expanded');
  const isOpen = row.getAttribute('aria-expanded') === 'true';
  let target = null;

  switch (e.key) {
    case 'ArrowDown': target = list[Math.min(i + 1, list.length - 1)]; break;
    case 'ArrowUp':   target = list[Math.max(i - 1, 0)]; break;
    case 'Home':      target = list[0]; break;
    case 'End':       target = list[list.length - 1]; break;
    case 'ArrowRight':
      if (expandable && !isOpen) { e.preventDefault(); setExpanded(row, true); }
      return;
    case 'ArrowLeft':
      if (expandable && isOpen) { e.preventDefault(); setExpanded(row, false); }
      return;
    default: return;
  }
  if (target) { e.preventDefault(); focusRow(target); }
});

Note

Two key points: (1) Arrow key navigation targets only "visible rows"(don't send focus to collapsed child rows). (2) When toggling with /, always keep aria-expanded and child row hidden in sync. Changing only the visual appearance won't communicate anything to assistive technologies.

Anti-Pattern (Bad)

Below is a "visually identical" thread list built with <div> and onclick only.It works with a mouse but is completely inoperable via keyboard.Try pressing Tab and arrow keys to feel the difference from the demo above.

Broken treegrid built with divs
From / Subject / Date
Yamada | Year-end party (3 messages) | 12/10

Try it: Tab doesn't focus any row, arrow keys can't navigate or expand. Screen readers don't recognize it as a 'table' or 'expandable row.'

<!-- ❌ Anti-pattern -->
<div class="grid">
  <div class="head">From / Subject / Date</div>
  <!-- div elements can't receive focus, and have no row/cell/expand semantics -->
  <div class="row" onclick="toggle('thread')">
    Yamada | Year-end party (3 messages) | 12/10 +
  </div>
  <div id="thread" style="display:none">
    <div class="row">Yamada | Re: Scheduling discussion | 12/10</div>
    <div class="row">Sato | Re: Restaurant booked | 12/11</div>
  </div>
</div>

Bad / Avoid

Problems with this implementation:

  • No table/row/cell semantics — Without role="treegrid" etc., screen readers hear "just a block of text." There's no cell-to-column association.
  • Expand/collapse state not conveyed — Without aria-expanded, users can't tell if a row is open or closed.
  • Not keyboard operablediv elements can't receive focus, so arrow key navigation, expansion, and collapse don't work.
  • No roving tabindex — Focus management doesn't exist at all.
  • Relies on inline display:none style, with no hidden attribute or state management.

Tip

If you must use <div>, you'll need to add role, tabindex, arrow key handling, and aria-expanded all by yourself. Starting with a native <table> gives you row, cell, and header associations for free.

Implementation Checklist


Source (English):Treegrid Pattern — W3C APG(opens in a new tab)