# One Tab Stop per Composite Widget

**MUST** · **ID:** `interactions-aria-composite-tab-stop` · **Category:** interactions
**Source:** [ARIA](https://www.w3.org/WAI/ARIA/apg/)
**Interactive version:** https://ui-guides-agent-rules.netlify.app/principles/interactions-aria-composite-tab-stop

> MUST: A composite widget (toolbar, listbox, menu, radiogroup, tablist, grid, tree) is ONE tab stop: roving `tabindex` — exactly one child at `0`, every other child at `-1` — with arrow keys moving the `0` and calling `.focus()`, and Tab moving past the whole widget. Twelve children must not be twelve tab stops. (Distinct from positive `tabindex`: here every value is `0` or `-1`; the bug is how many are `0`.)

Give a toolbar, listbox or grid a single tab stop and move within it using the arrow keys

Tab crosses widgets; arrow keys move within one. A toolbar, listbox, menu, radiogroup, tablist, grid or tree is ONE thing in the tab sequence, no matter how many children it has — that is what makes it a composite. Get this wrong and a twelve-item filter bar becomes twelve stops the user must Tab past to reach the form below, and the arrow keys, which is what a screen reader user will actually press inside a listbox, do nothing at all. There are two implementations, and this one is roving tabindex: exactly one child carries tabindex="0" while every other child carries tabindex="-1", so the browser can only Tab into the active child; your arrow-key handler then moves the 0 to the next child and calls .focus() on it. Because real DOM focus moves, the browser scrolls the active child into view and paints the focus ring for free. The alternative, aria-activedescendant, is a separate principle — reach for it only when focus must stay in a text input. Note this is not the same complaint as "Never Use Positive tabindex", which is about tabindex="1" and up wrecking the global document order; here every value is 0 or -1 and the bug is how many of them are 0.

## Rule snippet

```tsx
<button tabIndex={i === activeIndex ? 0 : -1} onKeyDown={onArrows} />
// ArrowRight: setActiveIndex(i + 1); itemRefs[i + 1].current.focus();
```

## Bad — do not do this

`interactions-aria-composite-tab-stop-bad`

```tsx
import { useEffect, useRef, useState } from 'react';

const FILTERS = [
  'All',
  'Open',
  'Closed',
  'Merged',
  'Draft',
  'Mine',
  'Assigned',
  'Mentioned',
  'Stale',
  'Blocked',
  'Ready',
  'Archived',
];

export function AriaCompositeTabStopBad() {
  const listRef = useRef<HTMLDivElement>(null);
  const [tabStops, setTabStops] = useState(0);
  const [visited, setVisited] = useState<string[]>([]);
  const [selected, setSelected] = useState('All');

  // Counted from the live DOM, not asserted in a caption.
  useEffect(() => {
    const options = listRef.current?.querySelectorAll<HTMLElement>('[role="option"]');
    setTabStops(
      Array.from(options ?? []).filter((el) => el.tabIndex >= 0).length
    );
  }, []);

  return (
    <div className="w-full max-w-sm">
      <button
        type="button"
        className="mb-3 rounded border border-border px-2 py-1 text-xs text-foreground focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
      >
        Start here, then hold Tab
      </button>

      {/* Every option is its own tab stop. The composite is not one widget to the
          keyboard — it is twelve unrelated stops in the page's tab sequence. */}
      <div
        ref={listRef}
        role="listbox"
        aria-label="Filters"
        className="flex flex-wrap gap-1 rounded-lg border border-border bg-card p-2"
      >
        {FILTERS.map((filter) => (
          <div
            key={filter}
            role="option"
            aria-selected={selected === filter}
            tabIndex={0}
            onFocus={() =>
              setVisited((prev) =>
                prev.includes(filter) ? prev : [...prev, filter]
              )
            }
            onClick={() => setSelected(filter)}
            onKeyDown={(e) => {
              if (e.key === 'Enter' || e.key === ' ') {
                e.preventDefault();
                setSelected(filter);
              }
            }}
            className={`cursor-pointer rounded px-2 py-1 text-xs focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring ${
              selected === filter
                ? 'bg-primary text-primary-foreground'
                : 'bg-muted text-foreground'
            }`}
          >
            {filter}
          </div>
        ))}
      </div>

      <p className="mt-3 rounded border border-border bg-muted px-3 py-2 text-xs text-foreground">
        Tab stops inside this one widget:{' '}
        <span className="font-medium text-destructive">{tabStops}</span>
        <br />
        Reached so far by tabbing:{' '}
        <span className="font-medium">{visited.length}</span> / {FILTERS.length}
      </p>

      <p className="mt-3 text-xs text-muted-foreground">
        Focus the button above and hold Tab. It takes twelve presses to get <em>past</em>{' '}
        a single filter bar, and the arrow keys — which is what a screen reader user
        will reach for inside a listbox — do nothing at all. Now imagine this widget
        sitting between the reader and the form they came for.
      </p>

      <p className="mt-2 text-xs text-destructive">
        A composite widget with 12 tab stops instead of 1
      </p>
    </div>
  );
}
```

## Good — do this

`interactions-aria-composite-tab-stop-good`

```tsx
import { useEffect, useRef, useState } from 'react';

const FILTERS = [
  'All',
  'Open',
  'Closed',
  'Merged',
  'Draft',
  'Mine',
  'Assigned',
  'Mentioned',
  'Stale',
  'Blocked',
  'Ready',
  'Archived',
];

export function AriaCompositeTabStopGood() {
  const listRef = useRef<HTMLDivElement>(null);
  const optionRefs = useRef<(HTMLDivElement | null)[]>([]);
  const [tabStops, setTabStops] = useState(0);
  const [active, setActive] = useState(0); // the roving tabindex="0"
  const [selected, setSelected] = useState('All');

  // Recounted from the live DOM every time the roving index moves — it stays 1.
  useEffect(() => {
    const options = listRef.current?.querySelectorAll<HTMLElement>('[role="option"]');
    setTabStops(
      Array.from(options ?? []).filter((el) => el.tabIndex >= 0).length
    );
  }, [active]);

  const moveTo = (index: number) => {
    const next = Math.min(Math.max(index, 0), FILTERS.length - 1);
    setActive(next);
    optionRefs.current[next]?.focus();
  };

  return (
    <div className="w-full max-w-sm">
      <button
        type="button"
        className="mb-3 rounded border border-border px-2 py-1 text-xs text-foreground focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
      >
        Start here, then press Tab
      </button>

      <div
        ref={listRef}
        role="listbox"
        aria-label="Filters"
        onKeyDown={(e) => {
          const keys = ['ArrowRight', 'ArrowDown', 'ArrowLeft', 'ArrowUp', 'Home', 'End'];
          if (!keys.includes(e.key)) return;
          e.preventDefault();
          e.stopPropagation();
          if (e.key === 'Home') moveTo(0);
          else if (e.key === 'End') moveTo(FILTERS.length - 1);
          else if (e.key === 'ArrowRight' || e.key === 'ArrowDown') moveTo(active + 1);
          else moveTo(active - 1);
        }}
        className="flex flex-wrap gap-1 rounded-lg border border-border bg-card p-2"
      >
        {FILTERS.map((filter, index) => (
          <div
            key={filter}
            ref={(el) => {
              optionRefs.current[index] = el;
            }}
            role="option"
            aria-selected={selected === filter}
            // Exactly one option is reachable by Tab. The other eleven are still
            // focusable programmatically (-1), which is how the arrows reach them.
            tabIndex={index === active ? 0 : -1}
            onClick={() => {
              setActive(index);
              setSelected(filter);
            }}
            onKeyDown={(e) => {
              if (e.key === 'Enter' || e.key === ' ') {
                e.preventDefault();
                setSelected(filter);
              }
            }}
            className={`cursor-pointer rounded px-2 py-1 text-xs focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring ${
              selected === filter
                ? 'bg-primary text-primary-foreground'
                : 'bg-muted text-foreground'
            }`}
          >
            {filter}
          </div>
        ))}
      </div>

      <p className="mt-3 rounded border border-border bg-muted px-3 py-2 text-xs text-foreground">
        Tab stops inside this one widget:{' '}
        <span className="font-medium text-success">{tabStops}</span>
        <br />
        Arrow keys currently on:{' '}
        <span className="font-medium">{FILTERS[active]}</span>
      </p>

      <p className="mt-3 text-xs text-muted-foreground">
        Focus the button above and press Tab <em>once</em> — you are in the filter bar.
        Arrow keys walk the options (Home / End jump to the ends), Enter or Space
        selects, and one more Tab leaves the whole widget. Tab crosses widgets; arrows
        move within one.
      </p>

      <p className="mt-2 text-xs text-success">
        Roving tabindex: one tab stop for the composite, arrows for its contents
      </p>
    </div>
  );
}
```

## References

- [APG: Developing a Keyboard Interface](https://www.w3.org/WAI/ARIA/apg/practices/keyboard-interface/)
- [APG: Toolbar pattern](https://www.w3.org/WAI/ARIA/apg/patterns/toolbar/)
