TreeGrid
A hierarchical data grid combining Grid's 2D navigation with TreeView's expandable rows.
Demo
Navigate with arrow keys. At rowheader, use ArrowRight/Left to expand/collapse. Press Space to select rows.
Use arrow keys to navigate between cells. At the first column (rowheader), ArrowRight expands collapsed rows and ArrowLeft collapses expanded rows. Press Space to select/deselect rows. Press Enter to activate a cell.
TreeGrid vs Grid
Use treegrid role for hierarchical data that can expand/collapse.
| Feature | TreeGrid | Grid |
|---|---|---|
| Hierarchy | Expandable/collapsible rows | Flat structure |
| Selection | Row selection (aria-selected on row) | Cell selection (aria-selected on cell) |
| Arrow at rowheader | Expand/collapse tree | Move focus |
| Required ARIA | aria-level, aria-expanded | None (hierarchy-specific) |
Accessibility Features
TreeGrid vs Grid
The treegrid role combines Grid's 2D keyboard navigation with TreeView's hierarchical expand/collapse functionality. Key differences from Grid:
- Rows can be expanded/collapsed to show/hide child rows
- Row selection instead of cell selection (
aria-selectedon row, not gridcell) - Tree operations (expand/collapse) only work at the rowheader column
- Rows have
aria-levelto indicate hierarchy depth
WAI-ARIA Roles
| Role | Target Element | Description |
|---|---|---|
treegrid | Container | The treegrid container (composite widget) |
row | Row container | Groups cells horizontally, may have children |
columnheader | Header cells | Column headers (not focusable) |
rowheader | First column cell | Row header where tree operations occur |
gridcell | Data cells | Interactive cells (focusable) |
W3C ARIA: treegrid role (opens in new tab)
WAI-ARIA Properties (TreeGrid Container)
| Attribute | Values | Required | Description |
|---|---|---|---|
role="treegrid" | - | Yes | Identifies the container as a treegrid |
aria-label | String | Yes (either aria-label or aria-labelledby) | Accessible name for the treegrid |
aria-labelledby | ID reference | Yes (either aria-label or aria-labelledby) | Alternative to aria-label |
aria-multiselectable | true | No | Only present for multi-select mode |
aria-rowcount | Number | No | Total rows (for virtualization) |
aria-colcount | Number | No | Total columns (for virtualization) |
* Either aria-label or aria-labelledby is required.
WAI-ARIA States (Rows)
| Attribute | Values | Required | Description |
|---|---|---|---|
aria-level | Number (1-based) | Yes | Static per row (determined by hierarchy) |
aria-expanded | true | false | Yes* | ArrowRight/Left at rowheader, click on expand icon |
aria-selected | true | false | No** | Space key, click (NOT on gridcell) |
aria-disabled | true | No | Only when row is disabled |
aria-rowindex | Number | No | Static (for virtualization) |
* Only parent rows (with children) have aria-expanded. Leaf rows do NOT have this attribute.
** When selection is supported, ALL rows should have aria-selected.
Keyboard Support
2D Navigation
| Key | Action |
|---|---|
| Arrow Down | Move focus to same column in next visible row |
| Arrow Up | Move focus to same column in previous visible row |
| Arrow Right | Move focus one cell right (at non-rowheader cells) |
| Arrow Left | Move focus one cell left (at non-rowheader cells) |
| Home | Move focus to first cell in row |
| End | Move focus to last cell in row |
| Ctrl + Home | Move focus to first cell in treegrid |
| Ctrl + End | Move focus to last cell in treegrid |
Tree Operations (at rowheader only)
| Key | Action |
|---|---|
| Arrow Right (at rowheader) | If collapsed parent: expand row. If expanded parent: move to first child's rowheader. If leaf: do nothing |
| Arrow Left (at rowheader) | If expanded parent: collapse row. If collapsed/leaf: move to parent's rowheader. If at root level collapsed: do nothing |
Row Selection & Cell Activation
| Key | Action |
|---|---|
| Space | Toggle row selection (NOT cell selection) |
| Enter | Activate focused cell (does NOT expand/collapse) |
| Ctrl + A | Select all visible rows (when multiselectable) |
Important: Arrow keys at non-rowheader cells only move focus, they do NOT expand/collapse.
Focus Management
This component uses roving tabindex for focus management:
- Roving tabindex - only one cell has
tabindex="0" -
tabindex="-1" - Single Tab stop (Tab enters/exits the grid)
- NOT focusable (no tabindex)
- NOT in keyboard navigation
- If focus was on child, move focus to parent
Key Differences from Grid
- Selection: Row selection (aria-selected on row) vs Cell selection in Grid
- Arrow keys at rowheader: Expand/collapse tree vs Move focus in Grid
- Enter key: Cell activation only (never expands/collapses)
- Hierarchy: aria-level and aria-expanded required on rows
- Navigation: Collapsed children are skipped in navigation
Source Code
<script lang="ts">
import { untrack } from 'svelte';
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
// =============================================================================
// Types
// =============================================================================
export interface TreeGridCellData {
id: string;
value: string | number;
disabled?: boolean;
colspan?: number;
}
export interface TreeGridNodeData {
id: string;
cells: TreeGridCellData[];
children?: TreeGridNodeData[];
disabled?: boolean;
}
export interface TreeGridColumnDef {
id: string;
header: string;
isRowHeader?: boolean;
}
interface FlatRow {
node: TreeGridNodeData;
level: number;
parentId: string | null;
hasChildren: boolean;
}
interface CellPosition {
rowIndex: number;
colIndex: number;
cell: TreeGridCellData;
rowId: string;
isRowHeader: boolean;
}
interface Props {
columns: TreeGridColumnDef[];
nodes: TreeGridNodeData[];
ariaLabel?: string;
ariaLabelledby?: string;
expandedIds?: string[];
defaultExpandedIds?: string[];
selectable?: boolean;
multiselectable?: boolean;
selectedRowIds?: string[];
defaultSelectedRowIds?: string[];
defaultFocusedCellId?: string;
totalRows?: number;
totalColumns?: number;
startRowIndex?: number;
startColIndex?: number;
enablePageNavigation?: boolean;
pageSize?: number;
onExpandedChange?: (expandedIds: string[]) => void;
onSelectionChange?: (selectedRowIds: string[]) => void;
onFocusChange?: (cellId: string | null) => void;
onCellActivate?: (cellId: string, rowId: string, colId: string) => void;
renderCell?: (cell: TreeGridCellData, rowId: string, colId: string) => string | number;
}
// =============================================================================
// Props
// =============================================================================
let {
columns,
nodes,
ariaLabel,
ariaLabelledby,
expandedIds: controlledExpandedIds,
defaultExpandedIds = [],
selectable = false,
multiselectable = false,
selectedRowIds: controlledSelectedRowIds,
defaultSelectedRowIds = [],
defaultFocusedCellId,
totalRows,
totalColumns,
startRowIndex = 2,
startColIndex = 1,
enablePageNavigation = false,
pageSize = 5,
onExpandedChange,
onSelectionChange,
onFocusChange,
onCellActivate,
renderCell,
}: Props = $props();
// =============================================================================
// State
// =============================================================================
let internalExpandedIds = new SvelteSet<string>(untrack(() => defaultExpandedIds));
let internalSelectedRowIds = new SvelteSet<string>(untrack(() => defaultSelectedRowIds));
let focusedCellIdState = $state<string | null>(null);
let initialized = $state(false);
let treegridRef: HTMLDivElement | null = $state(null);
let cellRefs: Map<string, HTMLDivElement> = new SvelteMap();
// =============================================================================
// Derived - Tree Flattening
// =============================================================================
function flattenTree(
treeNodes: TreeGridNodeData[],
level: number = 1,
parentId: string | null = null
): FlatRow[] {
const result: FlatRow[] = [];
for (const node of treeNodes) {
const hasChildren = Boolean(node.children && node.children.length > 0);
result.push({ node, level, parentId, hasChildren });
if (node.children) {
result.push(...flattenTree(node.children, level + 1, node.id));
}
}
return result;
}
const allRows = $derived(flattenTree(nodes));
const rowMap = $derived.by(() => {
const map = new SvelteMap<string, FlatRow>();
for (const flatRow of allRows) {
map.set(flatRow.node.id, flatRow);
}
return map;
});
const expandedIds = $derived(
controlledExpandedIds ? new SvelteSet(controlledExpandedIds) : internalExpandedIds
);
const selectedRowIds = $derived(
controlledSelectedRowIds ? new SvelteSet(controlledSelectedRowIds) : internalSelectedRowIds
);
const visibleRows = $derived.by(() => {
const result: FlatRow[] = [];
const collapsedParents = new SvelteSet<string>();
for (const flatRow of allRows) {
let isHidden = false;
let currentParentId = flatRow.parentId;
while (currentParentId) {
if (collapsedParents.has(currentParentId) || !expandedIds.has(currentParentId)) {
isHidden = true;
break;
}
const parent = rowMap.get(currentParentId);
currentParentId = parent?.parentId ?? null;
}
if (!isHidden) {
result.push(flatRow);
if (flatRow.hasChildren && !expandedIds.has(flatRow.node.id)) {
collapsedParents.add(flatRow.node.id);
}
}
}
return result;
});
const cellPositionMap = $derived.by(() => {
const map = new SvelteMap<string, CellPosition>();
visibleRows.forEach((flatRow, rowIndex) => {
flatRow.node.cells.forEach((cell, colIndex) => {
map.set(cell.id, {
rowIndex,
colIndex,
cell,
rowId: flatRow.node.id,
isRowHeader: columns[colIndex]?.isRowHeader ?? false,
});
});
});
return map;
});
const focusedCellId = $derived(
focusedCellIdState ?? defaultFocusedCellId ?? visibleRows[0]?.node.cells[0]?.id ?? null
);
// =============================================================================
// Effects
// =============================================================================
$effect(() => {
if (!initialized && nodes.length > 0) {
internalExpandedIds = new SvelteSet(defaultExpandedIds);
internalSelectedRowIds = new SvelteSet(defaultSelectedRowIds);
initialized = true;
}
});
$effect(() => {
if (treegridRef && nodes.length > 0) {
const focusableElements = treegridRef.querySelectorAll<HTMLElement>(
'[role="gridcell"] a[href], [role="gridcell"] button, [role="rowheader"] a[href], [role="rowheader"] button'
);
focusableElements.forEach((el) => {
el.setAttribute('tabindex', '-1');
});
}
});
function registerCell(node: HTMLDivElement, cellId: string) {
cellRefs.set(cellId, node);
return {
destroy() {
cellRefs.delete(cellId);
},
};
}
// =============================================================================
// Methods
// =============================================================================
function setFocusedCellId(id: string | null) {
focusedCellIdState = id;
onFocusChange?.(id);
}
function focusCell(cellId: string) {
const cellEl = cellRefs.get(cellId);
if (cellEl) {
const focusableChild = cellEl.querySelector<HTMLElement>(
'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
);
if (focusableChild) {
focusableChild.setAttribute('tabindex', '-1');
focusableChild.focus();
} else {
cellEl.focus();
}
setFocusedCellId(cellId);
}
}
function updateExpandedIds(newExpandedIds: Set<string>) {
if (!controlledExpandedIds) {
internalExpandedIds = newExpandedIds;
}
onExpandedChange?.([...newExpandedIds]);
}
function expandRow(rowId: string) {
const flatRow = rowMap.get(rowId);
if (!flatRow?.hasChildren || flatRow.node.disabled) return;
if (expandedIds.has(rowId)) return;
const newExpanded = new SvelteSet(expandedIds);
newExpanded.add(rowId);
updateExpandedIds(newExpanded);
}
function collapseRow(rowId: string, currentFocusCellId?: string) {
const flatRow = rowMap.get(rowId);
if (!flatRow?.hasChildren || flatRow.node.disabled) return;
if (!expandedIds.has(rowId)) return;
const newExpanded = new SvelteSet(expandedIds);
newExpanded.delete(rowId);
updateExpandedIds(newExpanded);
// If focus was on a child, move focus to the collapsed parent's first cell
if (currentFocusCellId) {
const focusPos = cellPositionMap.get(currentFocusCellId);
if (focusPos) {
const focusRowId = focusPos.rowId;
let currentRow = rowMap.get(focusRowId);
while (currentRow) {
if (currentRow.parentId === rowId) {
const parentRow = rowMap.get(rowId);
if (parentRow) {
const parentFirstCell = parentRow.node.cells[0];
if (parentFirstCell) {
// Use setTimeout to focus after Svelte updates the DOM
setTimeout(() => focusCell(parentFirstCell.id), 0);
}
}
break;
}
currentRow = currentRow.parentId ? rowMap.get(currentRow.parentId) : undefined;
}
}
}
}
function updateSelectedRowIds(newSelectedIds: Set<string>) {
if (!controlledSelectedRowIds) {
internalSelectedRowIds = newSelectedIds;
}
onSelectionChange?.([...newSelectedIds]);
}
function toggleRowSelection(rowId: string, rowDisabled?: boolean) {
if (!selectable || rowDisabled) return;
if (multiselectable) {
const newIds = new SvelteSet(selectedRowIds);
if (newIds.has(rowId)) {
newIds.delete(rowId);
} else {
newIds.add(rowId);
}
updateSelectedRowIds(newIds);
} else {
const newIds = selectedRowIds.has(rowId) ? new SvelteSet<string>() : new SvelteSet([rowId]);
updateSelectedRowIds(newIds);
}
}
function selectAllVisibleRows() {
if (!selectable || !multiselectable) return;
const allIds = new SvelteSet<string>();
for (const flatRow of visibleRows) {
if (!flatRow.node.disabled) {
allIds.add(flatRow.node.id);
}
}
updateSelectedRowIds(allIds);
}
function findNextVisibleRow(
startRowIndex: number,
direction: 'up' | 'down',
size = 1
): number | null {
if (direction === 'down') {
const targetIndex = Math.min(startRowIndex + size, visibleRows.length - 1);
return targetIndex > startRowIndex ? targetIndex : null;
} else {
const targetIndex = Math.max(startRowIndex - size, 0);
return targetIndex < startRowIndex ? targetIndex : null;
}
}
function handleKeyDown(event: KeyboardEvent, cell: TreeGridCellData, rowId: string) {
const pos = cellPositionMap.get(cell.id);
if (!pos) return;
const { rowIndex, colIndex, isRowHeader } = pos;
const flatRow = visibleRows[rowIndex];
const colCount = columns.length;
let handled = true;
switch (event.key) {
case 'ArrowRight': {
if (
isRowHeader &&
flatRow.hasChildren &&
!flatRow.node.disabled &&
!expandedIds.has(rowId)
) {
// Collapsed parent at rowheader: expand
expandRow(rowId);
} else {
// Expanded parent at rowheader, leaf row at rowheader, or non-rowheader: move right
if (colIndex < colCount - 1) {
const nextCell = flatRow.node.cells[colIndex + 1];
if (nextCell) focusCell(nextCell.id);
}
}
break;
}
case 'ArrowLeft': {
if (isRowHeader) {
if (flatRow.hasChildren && expandedIds.has(rowId) && !flatRow.node.disabled) {
collapseRow(rowId, cell.id);
} else if (flatRow.parentId) {
const parentRow = rowMap.get(flatRow.parentId);
if (parentRow) {
const parentVisibleIndex = visibleRows.findIndex(
(r) => r.node.id === flatRow.parentId
);
if (parentVisibleIndex !== -1) {
const parentCell = parentRow.node.cells[colIndex];
if (parentCell) focusCell(parentCell.id);
}
}
}
} else {
if (colIndex > 0) {
const prevCell = flatRow.node.cells[colIndex - 1];
if (prevCell) focusCell(prevCell.id);
}
}
break;
}
case 'ArrowDown': {
const nextRowIndex = findNextVisibleRow(rowIndex, 'down');
if (nextRowIndex !== null) {
const nextRow = visibleRows[nextRowIndex];
const nextCell = nextRow?.node.cells[colIndex];
if (nextCell) focusCell(nextCell.id);
}
break;
}
case 'ArrowUp': {
const prevRowIndex = findNextVisibleRow(rowIndex, 'up');
if (prevRowIndex !== null) {
const prevRow = visibleRows[prevRowIndex];
const prevCell = prevRow?.node.cells[colIndex];
if (prevCell) focusCell(prevCell.id);
}
break;
}
case 'Home': {
if (event.ctrlKey) {
const firstCell = visibleRows[0]?.node.cells[0];
if (firstCell) focusCell(firstCell.id);
} else {
const firstCellInRow = flatRow.node.cells[0];
if (firstCellInRow) focusCell(firstCellInRow.id);
}
break;
}
case 'End': {
if (event.ctrlKey) {
const lastRow = visibleRows[visibleRows.length - 1];
const lastCell = lastRow?.node.cells[lastRow.node.cells.length - 1];
if (lastCell) focusCell(lastCell.id);
} else {
const lastCellInRow = flatRow.node.cells[flatRow.node.cells.length - 1];
if (lastCellInRow) focusCell(lastCellInRow.id);
}
break;
}
case 'PageDown': {
if (enablePageNavigation) {
const targetRowIndex = Math.min(rowIndex + pageSize, visibleRows.length - 1);
const targetRow = visibleRows[targetRowIndex];
const targetCell = targetRow?.node.cells[colIndex];
if (targetCell) focusCell(targetCell.id);
} else {
handled = false;
}
break;
}
case 'PageUp': {
if (enablePageNavigation) {
const targetRowIndex = Math.max(rowIndex - pageSize, 0);
const targetRow = visibleRows[targetRowIndex];
const targetCell = targetRow?.node.cells[colIndex];
if (targetCell) focusCell(targetCell.id);
} else {
handled = false;
}
break;
}
case ' ': {
toggleRowSelection(rowId, flatRow.node.disabled);
break;
}
case 'Enter': {
if (!cell.disabled && !flatRow.node.disabled) {
onCellActivate?.(cell.id, rowId, columns[colIndex]?.id ?? '');
}
break;
}
case 'a': {
if (event.ctrlKey) {
selectAllVisibleRows();
} else {
handled = false;
}
break;
}
default:
handled = false;
}
if (handled) {
event.preventDefault();
event.stopPropagation();
}
}
// =============================================================================
// Helper Functions
// =============================================================================
function getRowAriaExpanded(flatRow: FlatRow): 'true' | 'false' | undefined {
if (!flatRow.hasChildren) return undefined;
return expandedIds.has(flatRow.node.id) ? 'true' : 'false';
}
function getRowAriaSelected(flatRow: FlatRow): 'true' | 'false' | undefined {
if (!selectable) return undefined;
return selectedRowIds.has(flatRow.node.id) ? 'true' : 'false';
}
function getCellRole(colIndex: number): 'rowheader' | 'gridcell' {
return columns[colIndex]?.isRowHeader ? 'rowheader' : 'gridcell';
}
function getCellPaddingLeft(flatRow: FlatRow, colIndex: number): string | undefined {
if (!columns[colIndex]?.isRowHeader) return undefined;
return `${(flatRow.level - 1) * 20 + 8}px`;
}
</script>
<div
bind:this={treegridRef}
role="treegrid"
aria-label={ariaLabel}
aria-labelledby={ariaLabelledby}
aria-multiselectable={multiselectable ? 'true' : undefined}
aria-rowcount={totalRows}
aria-colcount={totalColumns}
class="apg-treegrid"
>
<!-- Header Row -->
<div role="row" aria-rowindex={totalRows ? 1 : undefined}>
{#each columns as col, colIndex (col.id)}
<div role="columnheader" aria-colindex={totalColumns ? startColIndex + colIndex : undefined}>
{col.header}
</div>
{/each}
</div>
<!-- Data Rows -->
{#each visibleRows as flatRow, rowIndex (flatRow.node.id)}
<div
role="row"
aria-level={flatRow.level}
aria-expanded={getRowAriaExpanded(flatRow)}
aria-selected={getRowAriaSelected(flatRow)}
aria-disabled={flatRow.node.disabled ? 'true' : undefined}
aria-rowindex={totalRows ? startRowIndex + rowIndex : undefined}
>
{#each flatRow.node.cells as cell, colIndex (cell.id)}
{@const isFocused = cell.id === focusedCellId}
{@const isSelected = selectedRowIds.has(flatRow.node.id)}
{@const colId = columns[colIndex]?.id ?? ''}
{@const isRowHeaderCell = columns[colIndex]?.isRowHeader}
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
<div
role={getCellRole(colIndex)}
tabindex={isFocused ? 0 : -1}
aria-disabled={cell.disabled || flatRow.node.disabled ? 'true' : undefined}
aria-colindex={totalColumns ? startColIndex + colIndex : undefined}
aria-colspan={cell.colspan}
class="apg-treegrid-cell"
class:focused={isFocused}
class:selected={isSelected}
class:disabled={cell.disabled || flatRow.node.disabled}
style:padding-left={getCellPaddingLeft(flatRow, colIndex)}
onkeydown={(e) => handleKeyDown(e, cell, flatRow.node.id)}
onfocusin={() => setFocusedCellId(cell.id)}
use:registerCell={cell.id}
>
{#if isRowHeaderCell && flatRow.hasChildren}
<span class="expand-icon" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="9 6 15 12 9 18" />
</svg>
</span>
{/if}
{#if renderCell}
<!-- eslint-disable-next-line svelte/no-at-html-tags -- renderCell returns sanitized HTML from the consuming application -->
{@html renderCell(cell, flatRow.node.id, colId)}
{:else}
{cell.value}
{/if}
</div>
{/each}
</div>
{/each}
</div> Usage
<script lang="ts">
import TreeGrid from './TreeGrid.svelte';
const columns = [
{ id: 'name', header: 'Name', isRowHeader: true },
{ id: 'size', header: 'Size' },
];
const nodes = [
{
id: 'folder1',
cells: [
{ id: 'folder1-name', value: 'Documents' },
{ id: 'folder1-size', value: '--' },
],
children: [
{
id: 'file1',
cells: [
{ id: 'file1-name', value: 'Report.pdf' },
{ id: 'file1-size', value: '2.5 MB' },
],
},
],
},
];
let expandedIds = $state(['folder1']);
let selectedRowIds = $state([]);
</script>
<TreeGrid
{columns}
{nodes}
ariaLabel="File browser"
selectable
multiselectable
{expandedIds}
{selectedRowIds}
onExpandedChange={(ids) => expandedIds = ids}
onSelectionChange={(ids) => selectedRowIds = ids}
/> API
Props
| Prop | Type | Description |
|---|---|---|
columns | TreeGridColumnDef[] | Column definitions |
nodes | TreeGridNodeData[] | Hierarchical node data |
expandedIds | string[] | Expanded row IDs |
selectedRowIds | string[] | Selected row IDs |
onExpandedChange | (ids: string[]) => void | Expand state change callback |
onSelectionChange | (ids: string[]) => void | Selection change callback |
Resources
- WAI-ARIA APG: TreeGrid Pattern (opens in new tab)
- AI Implementation Guide (llm.md) (opens in new tab) - ARIA specs, keyboard support, test checklist