Loading Developer Playground

Loading ...

Skip to main content
Share:
ARIA ROLEโ€ขWidget Roles

treeitem

An item in a tree hierarchy. Tree items can be nested within other tree items and may be expandable to reveal children. Used in file browsers, navigation menus, and hierarchical data displays.

Parent Role
tree
Key Attributes
aria-expanded, aria-selected
Nesting Container
role="group"

Overview

The treeitem role represents an item within a tree widget. Tree items can be either leaf nodes (no children) or branch nodes (expandable with children). They support selection, expansion, and complex keyboard navigation.

Tree items with children use aria-expanded to indicate whether they are expanded or collapsed. Nested items are wrapped in a role="group" container.

Tree Structure

A tree consists of: role="tree" (the container), role="treeitem" (each node), and role="group" (wrapper for child nodes). Only one item should have tabindex="0" at a time.

Live Demo: File Tree Explorer

Interactive File Tree

  • ๐Ÿ“ Documents
    • ๐Ÿ“„ Resume.pdf
    • ๐Ÿ“„ Cover Letter.docx
    • ๐Ÿ“ Projects
      • ๐Ÿ“„ Project A
      • ๐Ÿ“„ Project B
  • ๐Ÿ“„ Notes.txt

Selected: documents

Keyboard support: Use โ†‘ โ†“ to navigate, โ†’ to expand, โ† to collapse, Enter to select.

Code Examples

Basic Tree Structure

<!-- Basic Tree Structure -->
<ul role="tree" aria-label="File explorer">
  <li role="treeitem" aria-expanded="true" aria-selected="false">
    Documents
    <ul role="group">
      <li role="treeitem" aria-selected="false">Resume.pdf</li>
      <li role="treeitem" aria-selected="true">Cover Letter.docx</li>
    </ul>
  </li>
  <li role="treeitem" aria-expanded="false" aria-selected="false">
    Images
    <ul role="group">
      <li role="treeitem" aria-selected="false">Photo1.jpg</li>
      <li role="treeitem" aria-selected="false">Photo2.png</li>
    </ul>
  </li>
</ul>

<!-- Key attributes:
     - role="treeitem" on each tree node
     - aria-expanded on parent nodes (true/false)
     - aria-selected on the focused/selected node
     - role="group" on nested <ul> containers -->

Keyboard Navigation

<!-- Tree with Full Keyboard Support -->
<ul role="tree" aria-label="Project files" id="file-tree">
  <li role="treeitem" aria-expanded="true" id="node-1" tabindex="0">
    src
    <ul role="group">
      <li role="treeitem" id="node-2" tabindex="-1">index.js</li>
      <li role="treeitem" aria-expanded="false" id="node-3" tabindex="-1">
        components
        <ul role="group">
          <li role="treeitem" id="node-4" tabindex="-1">Button.js</li>
          <li role="treeitem" id="node-5" tabindex="-1">Card.js</li>
        </ul>
      </li>
    </ul>
  </li>
</ul>

<script>
  const tree = document.getElementById('file-tree');
  const items = tree.querySelectorAll('[role="treeitem"]');
  let currentIndex = 0;

  tree.addEventListener('keydown', (e) => {
    const currentItem = items[currentIndex];
    const isExpanded = currentItem.getAttribute('aria-expanded') === 'true';
    const hasChildren = currentItem.querySelector('[role="group"]');

    switch (e.key) {
      case 'ArrowDown':
        e.preventDefault();
        moveFocus(1);
        break;
      case 'ArrowUp':
        e.preventDefault();
        moveFocus(-1);
        break;
      case 'ArrowRight':
        e.preventDefault();
        if (hasChildren && !isExpanded) {
          expandNode(currentItem);
        } else if (hasChildren && isExpanded) {
          // Move to first child
          moveFocus(1);
        }
        break;
      case 'ArrowLeft':
        e.preventDefault();
        if (hasChildren && isExpanded) {
          collapseNode(currentItem);
        } else {
          // Move to parent
          moveToParent(currentItem);
        }
        break;
      case 'Home':
        e.preventDefault();
        currentIndex = 0;
        updateFocus();
        break;
      case 'End':
        e.preventDefault();
        currentIndex = getVisibleItems().length - 1;
        updateFocus();
        break;
      case 'Enter':
      case ' ':
        e.preventDefault();
        if (hasChildren) {
          toggleNode(currentItem);
        } else {
          activateNode(currentItem);
        }
        break;
    }
  });

  function moveFocus(direction) {
    const visible = getVisibleItems();
    const newIndex = currentIndex + direction;
    if (newIndex >= 0 && newIndex < visible.length) {
      currentIndex = newIndex;
      updateFocus();
    }
  }

  function updateFocus() {
    items.forEach(item => item.setAttribute('tabindex', '-1'));
    const visible = getVisibleItems();
    visible[currentIndex].setAttribute('tabindex', '0');
    visible[currentIndex].focus();
  }

  function getVisibleItems() {
    return [...items].filter(item => {
      let parent = item.parentElement.closest('[role="treeitem"]');
      while (parent) {
        if (parent.getAttribute('aria-expanded') === 'false') {
          return false;
        }
        parent = parent.parentElement.closest('[role="treeitem"]');
      }
      return true;
    });
  }
</script>

Selection Modes

<!-- Tree with Selection Modes -->

<!-- Single Selection Tree -->
<ul role="tree" aria-label="Single select" aria-multiselectable="false">
  <li role="treeitem" aria-selected="true" tabindex="0">
    Selected Item
  </li>
  <li role="treeitem" aria-selected="false" tabindex="-1">
    Unselected Item
  </li>
</ul>

<!-- Multi-Selection Tree -->
<ul role="tree" aria-label="Multi select" aria-multiselectable="true">
  <li role="treeitem" aria-selected="true" tabindex="0">
    โ˜‘ Selected Item 1
  </li>
  <li role="treeitem" aria-selected="true" tabindex="-1">
    โ˜‘ Selected Item 2
  </li>
  <li role="treeitem" aria-selected="false" tabindex="-1">
    โ˜ Unselected Item
  </li>
</ul>

<!-- For multi-select trees:
     - Ctrl+Click toggles selection
     - Shift+Click selects range
     - Ctrl+A selects all
     - aria-multiselectable="true" on the tree -->

React Component

// React Tree Component
import { useState, useCallback, useRef, useEffect } from 'react';

interface TreeNode {
  id: string;
  label: string;
  children?: TreeNode[];
}

interface TreeProps {
  data: TreeNode[];
  label: string;
  onSelect?: (nodeId: string) => void;
}

function Tree({ data, label, onSelect }: TreeProps) {
  const [expanded, setExpanded] = useState<Set<string>>(new Set());
  const [selected, setSelected] = useState<string | null>(null);
  const treeRef = useRef<HTMLUListElement>(null);

  const toggleExpanded = useCallback((nodeId: string) => {
    setExpanded(prev => {
      const next = new Set(prev);
      if (next.has(nodeId)) {
        next.delete(nodeId);
      } else {
        next.add(nodeId);
      }
      return next;
    });
  }, []);

  const handleSelect = useCallback((nodeId: string) => {
    setSelected(nodeId);
    onSelect?.(nodeId);
  }, [onSelect]);

  const handleKeyDown = useCallback((
    e: React.KeyboardEvent,
    node: TreeNode,
    level: number
  ) => {
    const hasChildren = node.children && node.children.length > 0;
    const isExpanded = expanded.has(node.id);

    switch (e.key) {
      case 'ArrowRight':
        e.preventDefault();
        if (hasChildren && !isExpanded) {
          toggleExpanded(node.id);
        }
        break;
      case 'ArrowLeft':
        e.preventDefault();
        if (hasChildren && isExpanded) {
          toggleExpanded(node.id);
        }
        break;
      case 'Enter':
      case ' ':
        e.preventDefault();
        if (hasChildren) {
          toggleExpanded(node.id);
        }
        handleSelect(node.id);
        break;
    }
  }, [expanded, toggleExpanded, handleSelect]);

  const renderNode = (node: TreeNode, level: number = 1) => {
    const hasChildren = node.children && node.children.length > 0;
    const isExpanded = expanded.has(node.id);
    const isSelected = selected === node.id;

    return (
      <li
        key={node.id}
        role="treeitem"
        aria-expanded={hasChildren ? isExpanded : undefined}
        aria-selected={isSelected}
        aria-level={level}
        tabIndex={isSelected ? 0 : -1}
        onClick={(e) => {
          e.stopPropagation();
          handleSelect(node.id);
        }}
        onKeyDown={(e) => handleKeyDown(e, node, level)}
        className="tree-item"
      >
        <div className="tree-item-content" style={{ paddingLeft: level * 20 }}>
          {hasChildren && (
            <button
              onClick={(e) => {
                e.stopPropagation();
                toggleExpanded(node.id);
              }}
              aria-hidden="true"
              tabIndex={-1}
              className="expand-button"
            >
              {isExpanded ? 'โ–ผ' : 'โ–ถ'}
            </button>
          )}
          <span>{hasChildren ? '๐Ÿ“' : '๐Ÿ“„'} {node.label}</span>
        </div>
        {hasChildren && isExpanded && (
          <ul role="group">
            {node.children!.map(child => renderNode(child, level + 1))}
          </ul>
        )}
      </li>
    );
  };

  return (
    <ul
      ref={treeRef}
      role="tree"
      aria-label={label}
      className="tree"
    >
      {data.map(node => renderNode(node))}
    </ul>
  );
}

// Usage
const fileData = [
  {
    id: 'src',
    label: 'src',
    children: [
      { id: 'index', label: 'index.js' },
      {
        id: 'components',
        label: 'components',
        children: [
          { id: 'button', label: 'Button.tsx' },
          { id: 'card', label: 'Card.tsx' },
        ],
      },
    ],
  },
  { id: 'readme', label: 'README.md' },
];

<Tree
  data={fileData}
  label="Project files"
  onSelect={(id) => console.log('Selected:', id)}
/>

Drag and Drop

<!-- Tree with Drag and Drop -->
<ul role="tree" aria-label="Draggable tree">
  <li 
    role="treeitem" 
    aria-grabbed="false"
    draggable="true"
    id="drag-item-1"
  >
    Draggable Item 1
  </li>
  <li 
    role="treeitem"
    aria-grabbed="true"
    draggable="true"
    id="drag-item-2"
  >
    Currently Grabbed
  </li>
  <li 
    role="treeitem"
    aria-dropeffect="move"
    id="drop-target"
  >
    Drop Target
  </li>
</ul>

<script>
  // Drag start
  item.addEventListener('dragstart', (e) => {
    item.setAttribute('aria-grabbed', 'true');
    // Announce to screen readers
    announce('Grabbed ' + item.textContent);
  });

  // Drag end
  item.addEventListener('dragend', (e) => {
    item.setAttribute('aria-grabbed', 'false');
    announce('Dropped ' + item.textContent);
  });

  // For drop targets
  target.addEventListener('dragover', (e) => {
    e.preventDefault();
    target.setAttribute('aria-dropeffect', 'move');
  });
</script>

<!-- Note: Drag and drop requires significant
     additional keyboard support for accessibility -->

Checkbox Tree (Tri-state)

<!-- Tree with Checkboxes (Tri-state) -->
<ul role="tree" aria-label="Permissions">
  <li role="treeitem" aria-expanded="true">
    <span role="checkbox" aria-checked="mixed" tabindex="0">
      โ˜‘ All Permissions
    </span>
    <ul role="group">
      <li role="treeitem">
        <span role="checkbox" aria-checked="true" tabindex="-1">
          โœ“ Read
        </span>
      </li>
      <li role="treeitem">
        <span role="checkbox" aria-checked="true" tabindex="-1">
          โœ“ Write
        </span>
      </li>
      <li role="treeitem">
        <span role="checkbox" aria-checked="false" tabindex="-1">
          โ˜ Delete
        </span>
      </li>
    </ul>
  </li>
</ul>

<!-- Tri-state checkbox values:
     - aria-checked="true" - all descendants selected
     - aria-checked="false" - no descendants selected
     - aria-checked="mixed" - some descendants selected -->

Keyboard Support

โ†“
โ†’Moves focus to next visible treeitem

Skips collapsed children

โ†‘
โ†’Moves focus to previous visible treeitem

Skips collapsed children

โ†’
โ†’Expands collapsed node / moves to first child

If expanded, moves to first child

โ†
โ†’Collapses expanded node / moves to parent

If collapsed or leaf, moves to parent

Home
โ†’Moves focus to first treeitem

In the entire tree

End
โ†’Moves focus to last visible treeitem

Last visible item in tree

EnterSpace
โ†’Activates/selects item, toggles expansion

Behavior depends on item type

*
โ†’Expands all siblings at the current level

Asterisk key

Best Practices

โœ“

Use aria-expanded on parent nodes to indicate expand/collapse state

โœ“

Wrap child nodes in role="group" for proper structure

โœ“

Implement roving tabindex - only focused item has tabindex="0"

โœ“

Use aria-level to indicate nesting depth if not implicit

โœ“

Provide visual indicators for expandable vs leaf nodes

โœ“

Support type-ahead for jumping to items by first letter

ร—

Don't omit role="group" around nested treeitems

ร—

Don't forget to manage focus when nodes collapse

ร—

Don't use trees for simple navigation - use a menu or links

ร—

Don't nest trees within trees

Supported ARIA Attributes

aria-expanded

Whether the node is expanded (branch nodes only)

aria-selected

Whether the node is selected

aria-level

Nesting level (1-based, can be implicit from DOM)

aria-setsize

Total number of siblings

aria-posinset

Position within siblings (1-based)

aria-label

Accessible name for the treeitem

aria-grabbed

For drag-and-drop trees

aria-checked

For checkbox trees (true/false/mixed)

Common Use Cases

File system explorers
Organizational charts
Navigation sidebars
Category hierarchies
Settings/preferences panels
Permission structures
Comment threads (nested)
Folder/tag organization

Accessibility Notes

Focus Management

Use roving tabindex: only the currently focused item has tabindex="0", all others have tabindex="-1". When a node collapses, move focus to the collapsed parent if a child was focused.

Screen Reader Behavior

Screen readers announce treeitems with their label, level, position (e.g., "item 2 of 5"), and expanded state. Use aria-level, aria-setsize, and aria-posinset when nesting isn't implicit from the DOM.

Type-Ahead Navigation

Consider implementing type-ahead: when users type characters, focus moves to the next item starting with those characters. This helps users quickly navigate large trees.

Related Roles & Attributes

Specifications & Resources