# Tree views keyboards can't open and screen readers flatten

Source: https://easeweb.dev/learn/tree-views
Topics: Complex widgets > Tree views say how deep each item is and open with arrow keys (WCAG 1.3.1, 4.1.2, 2.1.1)
The fix: Either build the sidebar as nested lists of links, with a button that says whether it is open with aria-expanded opening each section; or build the full ARIA tree view: role="tree", treeitem and group nested as the sections are, aria-expanded on each section, one Tab stop and the arrow keys. A tree drawn flat must also give every item aria-level, aria-setsize and aria-posinset.
Test with: keyboard, screen reader, forced colors, zoom
References: https://www.w3.org/WAI/ARIA/apg/patterns/treeview/ https://www.w3.org/WAI/ARIA/apg/patterns/treeview/examples/treeview-navigation/ https://www.w3.org/WAI/ARIA/apg/patterns/disclosure/examples/disclosure-navigation/ https://www.w3.org/WAI/ARIA/apg/practices/keyboard-interface/ https://www.w3.org/WAI/WCAG22/Understanding/info-and-relationships.html https://www.w3.org/WAI/WCAG22/Understanding/keyboard.html https://www.w3.org/WAI/WCAG22/Understanding/name-role-value.html https://www.w3.org/TR/wai-aria-1.2/#tree

This is a recreated teaching example, not a finding about a named client. The code is a starting pattern: test it in your own product. No completed assistive-technology test is claimed here.

## What happens

An app's help centre has a sidebar of topics in three sections: Getting started, Billing and Account. Click a section and its pages unfold beneath it, indented, and its arrow turns down. Inside Billing, Payment methods is a section of its own, holding Cards and Bank transfer one level deeper. The sections are divs with click handlers. Put the mouse aside and press Tab: focus moves through the links that happen to be showing, Plans and prices and Invoices, then leaves the sidebar for Contact support. Getting started, Payment methods and Account can't be opened, so the pages inside them can't be reached. A screen reader hears the visible links one after another, with nothing to say they belong to Billing, and the section names as plain text with no sign that they open.

The team then makes it a tree the quick way: `role="tree"` on the sidebar, `role="treeitem"` on every section and link, and nothing else. For the keyboard, nothing changes: Tab still passes over every section. A screen reader now calls the sidebar a tree, but the arrow keys do nothing in it, every item is announced at level 1, "Invoices, tree item, level 1", and no section says whether it is open.

## Why it fails

Success criterion 2.1.1 Keyboard asks that everything the mouse can do, the keyboard can do. Opening a section is done only by clicking a div, which takes no focus, so the pages inside a closed section can't be reached without a mouse. Adding the tree roles changes nothing here: the sections still take no focus, and the arrow keys a tree promises do nothing.

Success criterion 4.1.2 Name, Role, Value asks that controls expose their role and state. The div sections expose neither: no role that says they can be pressed, and no expanded or collapsed state. The tree's sections have the role but still no `aria-expanded`, so a screen reader can't tell an open section from a closed one, or a section from a page.

Success criterion 1.3.1 Info and Relationships asks that structure shown on screen is in the code. The indentation shows that Invoices belongs to Billing and that Cards is a level deeper. In the divs, nothing in the markup says so. With the tree roles, browsers work out each item's level from the nesting of `group` elements, and with none there, every item is level 1. The roles make the wrong structure official.

## Who is affected

Help centres, documentation and account settings often put every page behind a sidebar like this, and a narrow screen usually moves the same sidebar into a menu, where the failure follows it. A section that can't be opened hides its pages from anyone who can't click it.

## What automation and AI miss

Divs with click handlers have no role, so axe has nothing to check in the first sidebar. The tree roles pass axe: every `treeitem` sits in a `tree`, and every one has a name. axe checks the shape of a tree's roles (a `treeitem` outside a `tree` or `group` is reported as `aria-required-parent`, and a `tree` with no items is sent for review under `aria-required-children`), never whether a key does anything or whether the levels match the indentation.

The audit presses the keys. In each tree whose items share one Tab stop, it focuses that item and presses the arrow keys; if focus never moves, the other items can't be reached, and that is reported. A section `treeitem` whose `group` is hidden but which has no `aria-expanded` is reported too. It sends to review a tree where more than one item is a Tab stop and the arrows do nothing, a tree with no `group` and no `aria-level` whose items are indented on screen, and a container named as a tree, built from divs, with clickable rows Tab never reaches. A div sidebar with no telling class names is not found, and no check can tell whether the levels make sense to a listener.

The second sidebar is what an AI fix can produce: it adds `role="tree"` and `role="treeitem"` to the existing divs and stops there, and the result passes axe. A fix can also put `tabindex="0"` on every row, or add `aria-level` but no `aria-expanded`.

## Before: sections built from clickable divs

```html
<div class="help-nav">
  <div class="section">Getting started</div>
  <div class="pages" hidden>
    <a href="/help/create-account">Create an account</a>
    <a href="/help/invite-your-team">Invite your team</a>
  </div>
  <div class="section is-open">Billing</div>
  <div class="pages">
    <a href="/help/plans">Plans and prices</a>
    <div class="section">Payment methods</div>
    <div class="pages" hidden>
      <a href="/help/cards">Cards</a>
      <a href="/help/bank-transfer">Bank transfer</a>
    </div>
    <a href="/help/invoices">Invoices</a>
  </div>
  <div class="section">Account</div>
  <div class="pages" hidden>
    <a href="/help/change-email">Change your email</a>
    <a href="/help/delete-account">Delete your account</a>
  </div>
</div>
```

```css
.help-nav a, .help-nav .section { display: block; }
.help-nav .pages { padding-left: 1rem; }
.help-nav .section { cursor: pointer; }
/* The arrow turns down while the section is open, by a class only. */
.help-nav .section::before { content: ''; display: inline-block; margin-right: 0.5em; padding: 3px; border: solid; border-width: 0 2px 2px 0; rotate: -45deg; }
.help-nav .section.is-open::before { rotate: 45deg; }
```

```js
for (const section of document.querySelectorAll('.help-nav .section')) {
  section.addEventListener('click', () => {
    const pages = section.nextElementSibling;
    pages.hidden = !pages.hidden;
    section.classList.toggle('is-open', !pages.hidden);
  });
}
```

The sections respond only to `click` and take no focus, so a closed section's pages are out of reach for the keyboard. Nothing in the markup says a section can be opened, whether it is, or that the links after it belong to it: the nesting is padding.

## Before: tree roles added to the same divs

```html
<div class="help-nav" role="tree" aria-label="Help topics">
  <div class="section" role="treeitem">Getting started</div>
  <div class="pages" hidden>
    <a href="/help/create-account" role="treeitem">Create an account</a>
    <a href="/help/invite-your-team" role="treeitem">Invite your team</a>
  </div>
  <div class="section is-open" role="treeitem">Billing</div>
  <div class="pages">
    <a href="/help/plans" role="treeitem">Plans and prices</a>
    <div class="section" role="treeitem">Payment methods</div>
    <div class="pages" hidden>
      <a href="/help/cards" role="treeitem">Cards</a>
      <a href="/help/bank-transfer" role="treeitem">Bank transfer</a>
    </div>
    <a href="/help/invoices" role="treeitem">Invoices</a>
  </div>
  <div class="section" role="treeitem">Account</div>
  <div class="pages" hidden>
    <a href="/help/change-email" role="treeitem">Change your email</a>
    <a href="/help/delete-account" role="treeitem">Delete your account</a>
  </div>
</div>
```

The CSS and the script are the ones above, unchanged; the only difference is the two roles. They promise a tree, with one Tab stop, the arrow keys, open and closed states and levels, and nothing delivers it. Tab still skips every section. NVDA switches to focus mode in a tree and hands the arrow keys to the page, which ignores them. With no `group` elements, the browser gives every item level 1, and no section says whether it is open. axe passes it.

## After: nested lists with a button for each section

```html
<nav class="help-nav" aria-label="Help topics">
  <ul>
    <li>
      <button type="button" aria-expanded="false" aria-controls="help-start">Getting started</button>
      <ul id="help-start" hidden>
        <li><a href="/help/create-account">Create an account</a></li>
        <li><a href="/help/invite-your-team">Invite your team</a></li>
      </ul>
    </li>
    <li>
      <button type="button" aria-expanded="true" aria-controls="help-billing">Billing</button>
      <ul id="help-billing">
        <li><a href="/help/plans">Plans and prices</a></li>
        <li>
          <button type="button" aria-expanded="false" aria-controls="help-payment">Payment methods</button>
          <ul id="help-payment" hidden>
            <li><a href="/help/cards">Cards</a></li>
            <li><a href="/help/bank-transfer">Bank transfer</a></li>
          </ul>
        </li>
        <li><a href="/help/invoices">Invoices</a></li>
      </ul>
    </li>
    <li>
      <button type="button" aria-expanded="false" aria-controls="help-account">Account</button>
      <ul id="help-account" hidden>
        <li><a href="/help/change-email">Change your email</a></li>
        <li><a href="/help/delete-account">Delete your account</a></li>
      </ul>
    </li>
  </ul>
</nav>
```

```css
.help-nav ul { list-style: none; margin: 0; padding: 0; }
.help-nav ul ul { padding-left: 1rem; }
/* The arrow is drawn from the state a screen reader reads. */
.help-nav button::before { content: ''; display: inline-block; margin-right: 0.5em; padding: 3px; border: solid; border-width: 0 2px 2px 0; rotate: -45deg; }
.help-nav button[aria-expanded='true']::before { rotate: 45deg; }
```

```js
for (const button of document.querySelectorAll('.help-nav button[aria-expanded]')) {
  const list = document.getElementById(button.getAttribute('aria-controls'));
  button.addEventListener('click', () => {
    const open = button.getAttribute('aria-expanded') === 'true';
    button.setAttribute('aria-expanded', String(!open));
    list.hidden = open;
  });
}
```

Each section is a native button, so Tab reaches it and Enter and Space open it, with no key handling to write, and `aria-expanded` makes a screen reader say "Billing, button, expanded" or "collapsed". Each section's pages are a list nested inside its list item, so the structure is in the markup: screen readers announce a list and how many items it has as you move into it, and some also say how deeply it is nested. `hidden` keeps a closed section's links out of the Tab order, and the open section's list follows its button, so the next Tab goes straight into it. This is the disclosure pattern that the ARIA Authoring Practices advise for navigation with expandable groups of links.

## After: a full ARIA tree view

```html
<nav class="help-nav" aria-label="Help topics">
  <ul role="tree" aria-label="Help topics">
    <li role="treeitem" aria-expanded="false" aria-labelledby="help-start" tabindex="0">
      <span id="help-start">Getting started</span>
      <ul role="group" hidden>
        <li role="none"><a role="treeitem" href="/help/create-account" tabindex="-1">Create an account</a></li>
        <li role="none"><a role="treeitem" href="/help/invite-your-team" tabindex="-1">Invite your team</a></li>
      </ul>
    </li>
    <li role="treeitem" aria-expanded="true" aria-labelledby="help-billing" tabindex="-1">
      <span id="help-billing">Billing</span>
      <ul role="group">
        <li role="none"><a role="treeitem" href="/help/plans" tabindex="-1">Plans and prices</a></li>
        <li role="treeitem" aria-expanded="false" aria-labelledby="help-payment" tabindex="-1">
          <span id="help-payment">Payment methods</span>
          <ul role="group" hidden>
            <li role="none"><a role="treeitem" href="/help/cards" tabindex="-1">Cards</a></li>
            <li role="none"><a role="treeitem" href="/help/bank-transfer" tabindex="-1">Bank transfer</a></li>
          </ul>
        </li>
        <li role="none"><a role="treeitem" href="/help/invoices" tabindex="-1">Invoices</a></li>
      </ul>
    </li>
    <li role="treeitem" aria-expanded="false" aria-labelledby="help-account" tabindex="-1">
      <span id="help-account">Account</span>
      <ul role="group" hidden>
        <li role="none"><a role="treeitem" href="/help/change-email" tabindex="-1">Change your email</a></li>
        <li role="none"><a role="treeitem" href="/help/delete-account" tabindex="-1">Delete your account</a></li>
      </ul>
    </li>
  </ul>
</nav>
```

```css
.help-nav ul { list-style: none; margin: 0; padding: 0; }
.help-nav [role='group'] { padding-left: 1rem; }
.help-nav a { display: block; }
/* A section's ring goes round its name, not round the open branch under it. */
.help-nav li[role='treeitem']:focus-visible { outline: none; }
.help-nav li[role='treeitem']:focus-visible > span { outline: 2px solid; outline-offset: 2px; }
/* The arrow is drawn from the state a screen reader reads. */
.help-nav [aria-expanded] > span::before { content: ''; display: inline-block; margin-right: 0.5em; padding: 3px; border: solid; border-width: 0 2px 2px 0; rotate: -45deg; }
.help-nav [aria-expanded='true'] > span::before { rotate: 45deg; }
```

```js
const tree = document.querySelector('[role="tree"]');
const items = () => [...tree.querySelectorAll('[role="treeitem"]')];
// The items people can move to: every one not inside a closed section.
const shown = () => items().filter((item) => !item.parentElement.closest('[role="group"][hidden]'));
const parentOf = (item) => item.parentElement.closest('[role="treeitem"]');
function setOpen(item, open) {
  item.setAttribute('aria-expanded', String(open));
  item.querySelector(':scope > [role="group"]').hidden = !open;
}
// One Tab stop: the item last moved to.
function moveTo(item) {
  for (const other of items()) other.tabIndex = other === item ? 0 : -1;
  item.focus();
}
tree.addEventListener('keydown', (event) => {
  const item = event.target.closest('[role="treeitem"]');
  const list = shown();
  const i = list.indexOf(item);
  const state = item.getAttribute('aria-expanded');
  switch (event.key) {
    case 'ArrowDown': if (list[i + 1]) moveTo(list[i + 1]); break;
    case 'ArrowUp': if (i > 0) moveTo(list[i - 1]); break;
    case 'Home': moveTo(list[0]); break;
    case 'End': moveTo(list.at(-1)); break;
    case 'ArrowRight':
      if (state === 'false') setOpen(item, true);
      else if (state === 'true') moveTo(list[i + 1]);
      break;
    case 'ArrowLeft':
      if (state === 'true') setOpen(item, false);
      else if (parentOf(item)) moveTo(parentOf(item));
      break;
    case 'Enter':
    case ' ':
      if (state !== null) setOpen(item, state === 'false');
      else if (event.key === ' ') item.click();
      else return; // Enter on a page follows its link.
      break;
    default:
      return;
  }
  event.preventDefault();
});
tree.addEventListener('click', (event) => {
  const item = event.target.closest('[role="treeitem"]');
  // A click in an open section's branch, between its items, is not a click on the section.
  if (!item || item.querySelector(':scope > [role="group"]')?.contains(event.target)) return;
  const state = item.getAttribute('aria-expanded');
  if (state !== null) setOpen(item, state === 'false');
  moveTo(item);
});
```

The whole tree is one Tab stop, and the arrow keys move through it as the ARIA Authoring Practices tree view describes: Down and Up to the next and previous item, Right to open a section or go into it, Left to close it or go back to its section, Home and End to the first and last item. Enter and Space open and close a section, and Enter follows a page's link. Each section's pages sit in a `group` inside it, so the browser works out each item's level, its position and the size of its set from the nesting, and a screen reader says "Invoices, tree item, 3 of 3, level 2". `aria-expanded` gives each section its state, and the arrows are drawn from it. A section takes its name from its own label through `aria-labelledby`. Browsers already leave a nested `group` out of a name drawn from content, as the Authoring Practices note, so this only makes the name explicit.

## Which option to use

Both pass. For a sidebar of pages, like this one, use the nested lists: they are links and buttons that every browser, screen reader and voice control program already knows, need a few lines of script, and leave people to move with Tab as they do on the rest of the page. The ARIA Authoring Practices say the same: their own tree navigation example notes that the disclosure pattern suits typical site navigation better, because few sites need the keyboard behaviour the tree role requires. Build the tree when the sidebar is a widget in an application, where people pick or act on items rather than go to pages, such as a folder picker or a file manager, and when there are too many items to tab through one by one. Then it needs all of it: the roles, the states, one Tab stop and every key.

## Implementation decisions

Mark the page that is open. In the lists, put `aria-current="page"` on its link, as [navigation that marks the current page only by its look](/learn/current-page) explains; in the tree, put it on its `treeitem`, and open the sections above it on load, so the current page is in view and is where Tab lands. `aria-selected` is for a tree where choosing an item is a step apart from going to it, such as picking a folder before pressing Move; a tree of links doesn't need it. In a tree where several items can be chosen, the ARIA Authoring Practices advise `aria-checked` instead.

The browser can only count levels from the nesting it can see. A tree that is drawn flat, or only partly, because it is long, virtualised or loads its branches on demand, needs `aria-level`, `aria-setsize` and `aria-posinset` on every item. The Authoring Practices also suggest declaring them if testing shows a browser and screen reader reporting the wrong values.

The tree view pattern also recommends type-ahead, where a letter moves to the next item that starts with it: for every tree, and especially one with more than seven items at the top level. This sidebar has three. The asterisk, which opens every section at the same level as the focused one, is optional. Right on a closed section opens it without moving focus, as the pattern requires, so people stay where they are; the next Right goes in.

Don't put links or buttons inside a `treeitem`: the item is the control, and a control inside it is a second Tab stop with nothing to say where it belongs. In the lists, the buttons and links are separate on purpose. On a narrow screen, the same lists or tree go into the menu panel; don't rebuild them as another widget there. Tree components in libraries vary: check that the one you use nests its groups, or sets the levels, and handles every key, before relying on it.

## Verify the fix

1. Put the mouse aside and Tab into the sidebar. Open every section, including the one inside Billing, and reach every page; in a tree, use the arrow keys, Home and End, and check that Tab leaves the tree in one press.
2. With a screen reader, move to each section and open and close it: it is announced as expanded or collapsed, and the arrow on screen matches.
3. Move to a page inside a section and one inside the deeper section. In the lists, the screen reader announces the list it is in; in a tree, it says the item's level and its position in its section, such as 3 of 3 at level 2.
4. In a tree, check every key against the tree view pattern: Right opens a closed section and then moves into it, Left closes an open section and then moves to its parent, and Enter on a page follows its link.
5. Turn on forced colors (Windows Contrast themes) and zoom to 200%. The arrows, the indentation and the focus ring are all still visible, and no page in the sidebar is cut off.

Record the OS, browser and assistive-technology versions, the build, the date, and the actual result of each step. Repeat after the sidebar, its component or its library changes.

## Limits

This is a starting pattern, not a guarantee for every application. The scripts are minimal sketches: the tree has no type-ahead, no multiple selection and no loading on demand. A dropdown menu across the top of a page is a different pattern, covered in [dropdown menus that open only on hover](/learn/nav-menus), and a single section that opens and closes is covered in [show and hide buttons that don't say whether they are open](/learn/show-hide-buttons). A file tree that also lets people move items by dragging them needs a way without dragging, as [drag-and-drop boards that only a mouse can use](/learn/drag-and-drop) explains. Rows with several columns, such as an inbox of threads, are a tree grid, a pattern of its own. Test the complete journey with your target assistive technology.

---

Get this guide in your coding agent: MCP https://easeweb.dev/mcp (setup at https://easeweb.dev/services/developers)
