Siddhant Deval
Siddhant Deval
frontend5 min read

WAI-ARIA Compound Widgets: Building Accessible Comboboxes, Menus, and Dialogs from Scratch

Building complex UI widgets requires strict adherence to WAI-ARIA Authoring Practices (APG). This article constructs a complete WAI-ARIA 1.2 Combobox with list autocomplete, tabbed panels, and accessible dropdowns from scratch, managing dual-state synchronization, virtual pointer tracking, and React compound component architecture.

WAI-ARIA 1.2 Compound Widgets: APG-Compliant Combobox & Tablist

Part 3 built the FocusStateMachine — a useReducer-backed automaton that owns focus transitions, applies inert through a Focus Guard, and models roving tabindex as a COMPOSITE_ACTIVE state. The tension Part 3 left open: the state machine provides the focus infrastructure, but the individual compound widget types — combobox, tablist, tree, menu — each have their own rich keyboard interaction contracts specified by WAI-ARIA 1.2 and the APG. Implementing the state transitions correctly requires knowing exactly which aria-* attributes drive AT announcements, which keyboard events the APG mandates, and how the WAI-ARIA role model maps to AT behavior.

This article builds two production-grade compound widgets from first principles: an APG-compliant combobox with aria-autocomplete="list" (used as the Portal Canvas workspace search) and a manual-activation tablist (used as the Portal Canvas settings panel tabs). Both are built against the WAI-ARIA 1.2 spec, verified against the APG interaction model, and wired to the focus state machine from Part 3.


1. The ARIA Role Model — Owned Relationships and Required Properties

Before building widgets, engineers must understand how the WAI-ARIA role model constrains element relationships. Every composite widget role specifies:

  • Required owned elements: element types that must be direct or descendant owners for the role to be valid
  • Required context: parent role that must exist for the element's role to be valid
  • Required states and properties: ARIA attributes that must be present for the role to work correctly

1.1 Role Ownership Constraints

Crucial Requirement

Putting an role="option" element inside a <div> that does not have role="listbox" violates the required context constraint. Screen readers in some AT will refuse to announce the option's selected state because the required ancestor role is missing. Always satisfy the ownership hierarchy.

1.2 Required Properties Reference

Role Required Properties Supported States Notes
combobox aria-expanded, aria-controls (popup ID) aria-disabled, aria-required WAI-ARIA 1.2: input IS the combobox
option — aria-selected, aria-disabled, aria-checked (multi-select) Must be owned by listbox
tab — aria-selected, aria-disabled Must be owned by tablist
tabpanel aria-labelledby (pointing to its tab) — Associates panel with its tab
dialog aria-labelledby or aria-label aria-modal aria-modal affects which content AT presents
alertdialog aria-labelledby + aria-describedby aria-modal Requires both title and description
menu — — Owns menuitem, menuitemcheckbox, menuitemradio
menuitem — aria-disabled, aria-expanded (for submenu) Must be owned by menu/menubar

2. The APG-Compliant Combobox

The combobox pattern is one of the most complex compound widgets in the APG. WAI-ARIA 1.2 revised the pattern significantly from 1.1 — the 1.1 pattern required a wrapper <div> with role="combobox" around the input; in 1.2, the <input> element itself is the combobox.

2.1 WAI-ARIA 1.2 Combobox Anatomy

TYPESCRIPT
// ❌ WAI-ARIA 1.1 pattern — wrapper div is the combobox (deprecated)
<div role="combobox" aria-expanded="false" aria-owns="listbox-id">
  <input type="text" />
</div>

// ✅ WAI-ARIA 1.2 pattern — input IS the combobox
<input
  type="text"
  role="combobox"
  aria-expanded="false"
  aria-controls="listbox-id"
  aria-autocomplete="list"
  aria-activedescendant={activeOptionId ?? undefined}
/>
<ul
  id="listbox-id"
  role="listbox"
  aria-label="Search suggestions"
>
  <li role="option" id="opt-1" aria-selected="false">Dashboard</li>
  <li role="option" id="opt-2" aria-selected="false">Workspace Settings</li>
</ul>
Crucial Requirement

The aria-activedescendant pattern is the correct way to move "virtual focus" within a listbox while keeping DOM focus on the combobox input. It tells the AT which option is currently highlighted. Never move actual DOM focus into the listbox options — this breaks the editing experience (the user can no longer type in the input).

2.2 Full Combobox Implementation

TYPESCRIPT
interface SearchOption {
  id: string;
  label: string;
  href: string;
}

interface ComboboxState {
  inputValue: string;
  isOpen: boolean;
  activeIndex: number | null;
  options: SearchOption[];
}

type ComboboxAction =
  | { type: 'INPUT'; value: string }
  | { type: 'OPEN'; options: SearchOption[] }
  | { type: 'CLOSE' }
  | { type: 'MOVE'; delta: -1 | 1 }
  | { type: 'SELECT'; index: number }
  | { type: 'CLEAR' };

function comboboxReducer(state: ComboboxState, action: ComboboxAction): ComboboxState {
  switch (action.type) {
    case 'INPUT':
      return { ...state, inputValue: action.value, isOpen: true, activeIndex: null };
    case 'OPEN':
      return { ...state, isOpen: true, options: action.options };
    case 'CLOSE':
      return { ...state, isOpen: false, activeIndex: null };
    case 'MOVE': {
      if (!state.isOpen || state.options.length === 0) return state;
      const current = state.activeIndex ?? -1;
      const next = (current + action.delta + state.options.length) % state.options.length;
      return { ...state, activeIndex: next };
    }
    case 'SELECT':
      return {
        ...state,
        isOpen: false,
        activeIndex: null,
        inputValue: state.options[action.index]?.label ?? state.inputValue,
      };
    case 'CLEAR':
      return { ...state, inputValue: '', isOpen: false, activeIndex: null };
    default:
      return state;
  }
}

function WorkspaceSearch({ allOptions }: { allOptions: SearchOption[] }) {
  const inputId = useId();
  const listboxId = useId();
  const inputRef = useRef<HTMLInputElement>(null);

  const [state, dispatch] = useReducer(comboboxReducer, {
    inputValue: '',
    isOpen: false,
    activeIndex: null,
    options: [],
  });

  // Filter options based on input
  const filteredOptions = useMemo(
    () =>
      allOptions.filter((opt) =>
        opt.label.toLowerCase().includes(state.inputValue.toLowerCase())
      ),
    [allOptions, state.inputValue]
  );

  const activeDescendantId =
    state.activeIndex !== null
      ? `${listboxId}-opt-${state.activeIndex}`
      : undefined;

  const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
    switch (e.key) {
      case 'ArrowDown':
        e.preventDefault();
        if (!state.isOpen) {
          dispatch({ type: 'OPEN', options: filteredOptions });
        }
        dispatch({ type: 'MOVE', delta: 1 });
        break;
      case 'ArrowUp':
        e.preventDefault();
        dispatch({ type: 'MOVE', delta: -1 });
        break;
      case 'Enter':
        e.preventDefault();
        if (state.activeIndex !== null) {
          dispatch({ type: 'SELECT', index: state.activeIndex });
        }
        break;
      case 'Escape':
        e.preventDefault();
        if (state.isOpen) {
          dispatch({ type: 'CLOSE' });
        } else {
          dispatch({ type: 'CLEAR' });
        }
        inputRef.current?.focus();
        break;
      case 'Tab':
        dispatch({ type: 'CLOSE' });
        break;
    }
  };

  return (
    <div className="combobox-wrapper">
      <label htmlFor={inputId}>
        Search workspace
      </label>
      <input
        ref={inputRef}
        id={inputId}
        type="text"
        role="combobox"
        aria-expanded={state.isOpen}
        aria-controls={listboxId}
        aria-autocomplete="list"
        aria-activedescendant={activeDescendantId}
        value={state.inputValue}
        onChange={(e) => {
          dispatch({ type: 'INPUT', value: e.target.value });
          dispatch({ type: 'OPEN', options: filteredOptions });
        }}
        onKeyDown={handleKeyDown}
        onBlur={() => {
          // Delay close to allow click on option to register
          setTimeout(() => dispatch({ type: 'CLOSE' }), 150);
        }}
      />

      {state.isOpen && filteredOptions.length > 0 && (
        <ul
          id={listboxId}
          role="listbox"
          aria-label="Search suggestions"
        >
          {filteredOptions.map((option, index) => (
            <li
              key={option.id}
              id={`${listboxId}-opt-${index}`}
              role="option"
              aria-selected={index === state.activeIndex}
              onMouseDown={(e) => {
                // Prevent input blur before click registers
                e.preventDefault();
                dispatch({ type: 'SELECT', index });
              }}
            >
              {option.label}
            </li>
          ))}
        </ul>
      )}
    </div>
  );
}

2.3 Screen Reader Announcement Trace

When a user types "work" and arrows to the second option:


3. The APG-Compliant Manual-Activation Tablist

The Portal Canvas settings panel uses a tablist to organize settings categories. The APG specifies two tablist activation models: Automatic (activating a tab immediately changes the panel) and Manual (activating a tab requires pressing Enter or Space). Manual activation is recommended when tab panel content is expensive to load.

3.1 Required ARIA Structure

HTML
<!-- Required element hierarchy for an accessible tablist -->
<div class="tab-container">
  <div role="tablist" aria-label="Settings categories">
    <button
      role="tab"
      id="tab-profile"
      aria-selected="true"
      aria-controls="panel-profile"
    >
      Profile
    </button>
    <button
      role="tab"
      id="tab-security"
      aria-selected="false"
      aria-controls="panel-security"
      tabindex="-1"
    >
      Security
    </button>
  </div>

  <div
    role="tabpanel"
    id="panel-profile"
    aria-labelledby="tab-profile"
  >
    <!-- Profile settings content -->
  </div>

  <div
    role="tabpanel"
    id="panel-security"
    aria-labelledby="tab-security"
    hidden
  >
    <!-- Security settings content -->
  </div>
</div>
Architectural Note

Tab panels that are not currently active must be hidden using the hidden attribute (or display: none). aria-hidden="true" alone is insufficient — it hides from AT but leaves the panel in the tab order. The hidden attribute removes the panel from both the tab order and the AXTree.

3.2 Full Tablist Implementation

TYPESCRIPT
interface TabDefinition {
  id: string;
  label: string;
  content: ReactNode;
}

interface TablistState {
  focusedIndex: number;   // Which tab has roving tabindex="0"
  selectedIndex: number;  // Which tab's panel is visible (manual activation)
}

type TablistAction =
  | { type: 'FOCUS_TAB'; index: number }
  | { type: 'SELECT_FOCUSED' }      // Enter or Space
  | { type: 'MOVE'; delta: -1 | 1 };

function tablistReducer(state: TablistState, action: TablistAction): TablistState {
  switch (action.type) {
    case 'FOCUS_TAB':
      return { ...state, focusedIndex: action.index };
    case 'SELECT_FOCUSED':
      return { ...state, selectedIndex: state.focusedIndex };
    case 'MOVE': {
      // Manual activation: move focus only, do not select
      const { focusedIndex } = state;
      return state; // Handled by COMPOSITE_MOVE in focus machine — see tabRefs effect
    }
    default:
      return state;
  }
}

function SettingsTablist({ tabs }: { tabs: TabDefinition[] }) {
  const tablistId = useId();
  const [state, dispatch] = useReducer(tablistReducer, {
    focusedIndex: 0,
    selectedIndex: 0,
  });
  const tabRefs = useRef<(HTMLButtonElement | null)[]>([]);

  const handleKeyDown = (e: React.KeyboardEvent, index: number) => {
    let nextIndex = index;
    switch (e.key) {
      case 'ArrowRight':
        e.preventDefault();
        nextIndex = (index + 1) % tabs.length;
        break;
      case 'ArrowLeft':
        e.preventDefault();
        nextIndex = (index - 1 + tabs.length) % tabs.length;
        break;
      case 'Home':
        e.preventDefault();
        nextIndex = 0;
        break;
      case 'End':
        e.preventDefault();
        nextIndex = tabs.length - 1;
        break;
      case 'Enter':
      case ' ':
        e.preventDefault();
        dispatch({ type: 'SELECT_FOCUSED' });
        return;
      default:
        return;
    }
    dispatch({ type: 'FOCUS_TAB', index: nextIndex });
    tabRefs.current[nextIndex]?.focus();
  };

  return (
    <div>
      <div role="tablist" aria-label="Settings categories">
        {tabs.map((tab, index) => (
          <button
            key={tab.id}
            ref={(el) => { tabRefs.current[index] = el; }}
            role="tab"
            id={`${tablistId}-tab-${tab.id}`}
            aria-selected={index === state.selectedIndex}
            aria-controls={`${tablistId}-panel-${tab.id}`}
            tabIndex={index === state.focusedIndex ? 0 : -1}
            onClick={() => {
              dispatch({ type: 'FOCUS_TAB', index });
              dispatch({ type: 'SELECT_FOCUSED' });
            }}
            onKeyDown={(e) => handleKeyDown(e, index)}
          >
            {tab.label}
          </button>
        ))}
      </div>

      {tabs.map((tab, index) => (
        <div
          key={tab.id}
          role="tabpanel"
          id={`${tablistId}-panel-${tab.id}`}
          aria-labelledby={`${tablistId}-tab-${tab.id}`}
          hidden={index !== state.selectedIndex}
        >
          {tab.content}
        </div>
      ))}
    </div>
  );
}

3.3 APG Keyboard Interaction Summary

Key Action (Manual Activation Tablist)
→ Arrow Moves focus to next tab; wraps to first
← Arrow Moves focus to previous tab; wraps to last
Home Moves focus to first tab
End Moves focus to last tab
Enter / Space Activates focused tab (shows its panel)
Tab Moves focus to the active tab panel
Shift+Tab (from panel) Returns focus to the active tab
Performance / Safety Warning

In automatic activation mode, arrow keys both move focus AND activate the tab. This causes an accessibility problem when tab content loads asynchronously: the user might arrow through multiple tabs and trigger multiple expensive loads. Only use automatic activation for tabs with locally-available content. Use manual activation for any tab whose panel content involves a network request.


4. ARIA Live Region Integration for Widget Feedback

Compound widgets that perform asynchronous operations need to communicate status to screen reader users. Live regions (covered in full in Part 5) are introduced here for their widget-specific applications.

TYPESCRIPT
// ✅ Announce search result count to screen reader users as they type
function SearchResultsAnnouncer({ count, query }: { count: number; query: string }) {
  return (
    // role="status" is equivalent to aria-live="polite" aria-atomic="true"
    // announces count changes without interrupting current speech
    <div role="status" className="sr-only">
      {query.length > 0
        ? `${count} result${count !== 1 ? 's' : ''} for "${query}"`
        : ''}
    </div>
  );
}

// ✅ Announce tab selection to confirm manual activation
function TabSelectionAnnouncer({ selectedLabel }: { selectedLabel: string }) {
  return (
    <div role="status" className="sr-only">
      {selectedLabel} panel selected
    </div>
  );
}

Summary

Concept Rule
WAI-ARIA 1.2 combobox: the <input> is the combobox; virtual focus moves via aria-activedescendant; DOM focus stays on the input throughout the interaction. Never move DOM focus into the listbox — use aria-activedescendant to communicate the highlighted option.
Tablist manual activation: arrow keys move focus only; Enter/Space activates; a separate focusedIndex tracks roving tabindex independent of selectedIndex. Decouple focus position from selection state in manual-activation tablists.
ARIA ownership hierarchy must be satisfied: role="option" must be owned by role="listbox"; role="tab" must be owned by role="tablist". Violations cause AT to not announce required states — test against the AXTree, not just visual output.
Inactive tab panels must be hidden with the hidden attribute, not just aria-hidden="true", to remove them from both the AXTree and the tab order. Use hidden (or display: none) to suppress inactive panels; aria-hidden alone leaves them keyboard-navigable.

What's Next

Part 5 builds the live region system: the difference between aria-live="polite" and aria-live="assertive", why a single global announcer avoids duplicate announcements, how to architect a streaming content announcer for the Portal Canvas activity feed that doesn't interrupt the user's current reading position, and the failure modes of poorly-timed live region injections.

Research & Synthesis Note

This article was developed with AI-assisted deep search, specification cross-referencing, and technical research synthesis.

#Accessibility#a11y#WAI-ARIA#Combobox#Compound Components#Design Systems#React
Siddhant Deval

Written by Siddhant Deval

Senior Full-Stack Engineer building high-scale architectures, browser performance engineering systems, and SaaS platforms.