Need help with your JSON?

Try our JSON Formatter tool to automatically identify and fix syntax errors in your JSON. JSON Formatter tool

Implementing Keyboard Navigation in JSON Tree Views

A JSON tree view is a composite widget, not just a nested list with click handlers. If you want keyboard users to move through objects, arrays, and values efficiently, the tree needs one predictable tab stop, arrow-key navigation inside the widget, correct ARIA, and a clear distinction between focus and selection.

Current guidance from the WAI-ARIA Authoring Practices tree view pattern and MDN still expects the same core behavior in March 2026: Right Arrow opens or enters a branch, Left Arrow closes or moves to the parent, Home and End jump to the first and last visible node, and type-ahead is recommended for larger trees. If your JSON viewer does not implement that contract, it will feel broken to keyboard and assistive-technology users.

Before You Reach For role="tree"

Use a real tree only when users need desktop-style navigation inside a hierarchical widget. If your JSON page simply expands and collapses sections, a disclosure pattern with buttons is often easier to build and easier for web users to understand. MDN explicitly warns that tree views behave more like native apps than ordinary web content.

What Users Expect From a Tree

The keyboard model should match the current WAI-ARIA tree pattern. A JSON inspector that only handles ArrowUp and ArrowDown misses important behavior.

KeyExpected behavior in a JSON tree
Tab / Shift+TabMoves into or out of the tree. Only one tree item should be in the tab order.
ArrowDown / ArrowUpMoves to the next or previous visible node without expanding or collapsing.
ArrowRightOn a closed parent node, expand it. On an open parent node, move to its first child. On a leaf node, do nothing.
ArrowLeftOn an open parent node, collapse it. On a closed node or leaf, move to the parent if one exists.
Home / EndJump to the first or last visible node.
Enter / SpaceActivate the focused node. In a read-only JSON viewer, that usually means toggling expansion for branches and optionally selecting or copying a leaf value.
Printable charactersType-ahead is recommended, especially once the tree has more than a handful of root items.

The State Model That Keeps Navigation Predictable

The easiest way to avoid edge-case bugs is to treat keyboard navigation as derived state, not ad-hoc DOM traversal. A practical JSON tree usually needs:

  • A normalized node map with stable IDs, parent IDs, labels, levels, and child IDs.
  • An expandedIds set so you can derive which nodes are currently visible.
  • An activeId for keyboard focus and, if selection matters, a separate selectedId or selectedIds.
  • A depth-first visibleIds array so ArrowUp, ArrowDown,Home, and End are trivial.
  • A ref map from node ID to DOM element so focus can move without querying the whole document every time.
  • A short-lived type-ahead buffer if users need to jump to property names quickly.

Implementation Example in React

In React, a roving tabIndex model is often the simplest place to start: only the active tree item gets tabIndex=0, every other item gets -1, and the active item receives DOM focus when activeId changes. The code below shows the core pattern.

Practical Tree Skeleton (React/TSX)

import React, { useEffect, useMemo, useRef, useState } from 'react';

type TreeNode = {
  id: string;
  label: string;
  level: number;
  parentId: string | null;
  childIds: string[];
  valuePreview: string;
};

type Props = {
  nodes: Map<string, TreeNode>;
  rootIds: string[];
};

export function JsonTree({ nodes, rootIds }: Props) {
  const [expandedIds, setExpandedIds] = useState<Set<string>>(() => new Set(rootIds));
  const [activeId, setActiveId] = useState<string | null>(rootIds[0] ?? null);
  const [selectedId, setSelectedId] = useState<string | null>(null);
  const itemRefs = useRef(new Map<string, HTMLLIElement>());
  const typeaheadRef = useRef<{ value: string; timeout?: number }>({ value: '' });

  const visibleIds = useMemo(
    () => buildVisibleIds(rootIds, nodes, expandedIds),
    [rootIds, nodes, expandedIds],
  );

  useEffect(() => {
    if (!activeId) return;
    const element = itemRefs.current.get(activeId);
    element?.focus();
    element?.scrollIntoView({ block: 'nearest' });
  }, [activeId]);

  const isExpanded = (id: string) => expandedIds.has(id);

  const toggleExpanded = (id: string) => {
    setExpandedIds((previous) => {
      const next = new Set(previous);
      next.has(id) ? next.delete(id) : next.add(id);
      return next;
    });
  };

  const moveByOffset = (offset: number) => {
    if (!activeId) return;
    const currentIndex = visibleIds.indexOf(activeId);
    const nextId = visibleIds[currentIndex + offset];
    if (nextId) setActiveId(nextId);
  };

  const runTypeahead = (character: string) => {
    const buffer = (typeaheadRef.current.value + character).toLowerCase();
    typeaheadRef.current.value = buffer;
    window.clearTimeout(typeaheadRef.current.timeout);
    typeaheadRef.current.timeout = window.setTimeout(() => {
      typeaheadRef.current.value = '';
    }, 500);

    const startIndex = activeId ? visibleIds.indexOf(activeId) + 1 : 0;
    const searchOrder = [...visibleIds.slice(startIndex), ...visibleIds.slice(0, startIndex)];
    const match = searchOrder.find((id) => nodes.get(id)?.label.toLowerCase().startsWith(buffer));
    if (match) setActiveId(match);
  };

  const onKeyDown = (event: React.KeyboardEvent<HTMLUListElement>) => {
    if (!activeId) return;

    const node = nodes.get(activeId)!;
    const parentId = node.parentId;
    const firstChildId = node.childIds[0];
    const hasChildren = node.childIds.length > 0;

    switch (event.key) {
      case 'ArrowDown':
        event.preventDefault();
        moveByOffset(1);
        break;
      case 'ArrowUp':
        event.preventDefault();
        moveByOffset(-1);
        break;
      case 'ArrowRight':
        event.preventDefault();
        if (!hasChildren) break;
        if (!isExpanded(node.id)) toggleExpanded(node.id);
        else if (firstChildId) setActiveId(firstChildId);
        break;
      case 'ArrowLeft':
        event.preventDefault();
        if (hasChildren && isExpanded(node.id)) toggleExpanded(node.id);
        else if (parentId) setActiveId(parentId);
        break;
      case 'Home':
        event.preventDefault();
        setActiveId(visibleIds[0] ?? activeId);
        break;
      case 'End':
        event.preventDefault();
        setActiveId(visibleIds.at(-1) ?? activeId);
        break;
      case 'Enter':
      case ' ':
        event.preventDefault();
        if (hasChildren) toggleExpanded(node.id);
        else setSelectedId(node.id);
        break;
      default:
        if (event.key.length === 1 && /\S/.test(event.key)) runTypeahead(event.key);
    }
  };

  const renderNode = (id: string): React.ReactNode => {
    const node = nodes.get(id)!;
    const hasChildren = node.childIds.length > 0;
    const expanded = isExpanded(id);

    return (
      <li
        key={id}
        ref={(element) => {
          if (element) itemRefs.current.set(id, element);
          else itemRefs.current.delete(id);
        }}
        role='treeitem'
        tabIndex={activeId === id ? 0 : -1}
        aria-level={node.level}
        aria-selected={selectedId === id ? true : undefined}
        aria-expanded={hasChildren ? expanded : undefined}
        onClick={() => setActiveId(id)}
        className={activeId === id ? 'outline outline-2 outline-sky-600' : undefined}
      >
        <span className='font-medium'>{node.label}</span>{' '}
        <span className='text-gray-500'>{node.valuePreview}</span>

        {hasChildren && expanded ? (
          <ul role='group' className='pl-4'>
            {node.childIds.map(renderNode)}
          </ul>
        ) : null}
      </li>
    );
  };

  return (
    <ul role='tree' aria-label='JSON document structure' onKeyDown={onKeyDown}>
      {rootIds.map(renderNode)}
    </ul>
  );
}

function buildVisibleIds(
  rootIds: string[],
  nodes: Map<string, TreeNode>,
  expandedIds: Set<string>,
) {
  const visible: string[] = [];

  const visit = (id: string) => {
    visible.push(id);
    if (!expandedIds.has(id)) return;
    nodes.get(id)?.childIds.forEach(visit);
  };

  rootIds.forEach(visit);
  return visible;
}

This example keeps the keyboard logic in one place, derives visible nodes from expansion state, moves real DOM focus onto the active tree item, and omits aria-expanded on leaf nodes. That combination aligns with current APG and MDN tree guidance far better than attaching a few independent key handlers to nested list items.

Roving `tabIndex` vs `aria-activedescendant`

Both approaches are valid. The WAI-ARIA tree pattern explicitly allows aria-activedescendant on the tree container as an alternative to moving DOM focus between items.

  • Roving `tabIndex`: Simpler to reason about when every visible tree item already exists in the DOM. Browser focus lands on the actual node, which makes debugging and focus styling straightforward.
  • `aria-activedescendant`: Useful when focus must stay on the tree container or another controlling element, but you must keep IDs stable and ensure the active descendant always points to a rendered node.
  • Practical default: For most React JSON viewers, start with rovingtabIndex. Move to aria-activedescendant only if your widget architecture strongly benefits from container-level focus.

Common Bugs and Edge Cases

Most broken tree views fail on the same small set of details:

  • Putting every node in the tab order. A tree should behave like one composite widget, not 50 independent tab stops.
  • Mixing focus and selection. A focused node is where keyboard input goes. A selected node is what the app has chosen. These are not always the same thing.
  • Setting aria-expanded on leaves. Only nodes with children should expose expanded or collapsed state.
  • Collapsing a branch while focus is still inside a hidden descendant. If the current node disappears, move focus to the collapsing parent immediately.
  • Forgetting an accessible name on the tree itself. Add aria-label or aria-labelledby.
  • Virtualizing or lazy-loading descendants without supplying aria-level,aria-posinset, and aria-setsize when the full branch is not in the DOM.
  • Overriding the focus ring with subtle styling. Keyboard focus must stay obvious in normal, dark, and high-contrast modes.

Testing Checklist

  • Press Tab once to enter the tree and once more to leave it. If you need many tabs to cross the widget, the focus model is wrong.
  • Verify ArrowUp, ArrowDown, ArrowLeft, and ArrowRight do not scroll the page while the tree has focus.
  • Confirm that collapsing a parent keeps focus on a visible item and that expanding a node preserves the correct reading order.
  • Test with a screen reader and check that level, expanded state, and selection state are announced accurately.
  • If the tree is large, verify that type-ahead jumps to the next matching property name instead of always restarting from the top.
  • Make sure mouse clicks, touch interaction, and keyboard navigation all update the same active node state.

Conclusion

A usable JSON tree view is mostly about discipline: derive a visible-node list, keep one active item in the tab order, implement the full arrow-key contract, and expose the right ARIA states. If you do that, keyboard navigation becomes predictable instead of fragile.

For current reference material, review the WAI-ARIA Authoring Practices tree view pattern and MDN's `tree` role guide. Those documents are the clearest source of truth for expected behavior.

Need help with your JSON?

Try our JSON Formatter tool to automatically identify and fix syntax errors in your JSON. JSON Formatter tool