Skip to content

Tabs

Tabs · roving tabindex + arrow keys

One panel visible at a time, chosen from a row of tabs. Teaches the roving-tabindex idea and arrow-key navigation that trip up most hand-rolled versions.

Live example

Real and interactive — use it with the mouse, or Tab to it and use the keys below.

The Web Content Accessibility Guidelines — the technical baseline that nearly every law points back to.

Keyboard

KeyAction
Left / Right Arrowmoves to the previous / next tab and selects it, wrapping at the ends
Home / Endmoves to the first / last tab
Tableaves the tablist and moves into the selected panel

Screen reader

  • The tablist is announced with its accessible name; each tab reports whether it is selected and its position (“2 of 3”).
  • Arrowing to another tab re-announces it; the shown panel is labelled by its tab.

ARIA notes

  • Roving tabindex: only the selected tab has tabindex 0, the rest tabindex -1, so Tab does not walk through every tab.
  • role=“tab” + aria-selected on each tab; role=“tabpanel” + aria-labelledby on each panel; aria-controls links them.
  • Hidden panels use the hidden attribute, removing them from both the tab order and the accessibility tree.

Code

The real source of the example above — copy it and it works. This is the file that renders on this page, so the code and the live example can never drift apart.

src/components/library/Tabs.tsx
import { useId, useRef, useState, type ReactNode } from 'react'

// Accessible tabs — the WAI-ARIA Tabs pattern with a roving tabindex.
//
// The tablist holds exactly one tab in the Tab sequence at a time: the selected
// tab has tabindex 0, the rest tabindex -1. So Tab moves the user *out* of the
// tablist to the panel, while Left/Right (and Home/End) move *between* tabs and
// move focus with them. This is the part people most often get wrong — either
// every tab is a tab stop (so Tab walks through all of them), or the arrow keys
// do nothing.
//
// Activation follows focus (Left/Right selects immediately). That is the correct
// default when showing a panel is cheap; for expensive panels the pattern allows
// manual activation (arrows move focus, Enter/Space selects) — out of scope for
// this demo, and called out in the pitfalls.

export interface TabItem {
  label: string
  content: ReactNode
}

export function Tabs({ tabs, label }: { tabs: TabItem[]; label: string }) {
  const baseId = useId()
  const [selected, setSelected] = useState(0)
  const tabRefs = useRef<(HTMLButtonElement | null)[]>([])

  function onKeyDown(e: React.KeyboardEvent) {
    const last = tabs.length - 1
    let next: number | null = null
    if (e.key === 'ArrowRight') next = selected === last ? 0 : selected + 1
    else if (e.key === 'ArrowLeft') next = selected === 0 ? last : selected - 1
    else if (e.key === 'Home') next = 0
    else if (e.key === 'End') next = last
    if (next === null) return
    e.preventDefault()
    setSelected(next)
    tabRefs.current[next]?.focus()
  }

  return (
    <div>
      <div role="tablist" aria-label={label} onKeyDown={onKeyDown} className="flex flex-wrap gap-1 border-b border-outline-variant">
        {tabs.map((t, i) => {
          const isSel = i === selected
          return (
            <button
              key={i}
              ref={(el) => {
                tabRefs.current[i] = el
              }}
              type="button"
              role="tab"
              id={`${baseId}-tab-${i}`}
              aria-selected={isSel}
              aria-controls={`${baseId}-panel-${i}`}
              tabIndex={isSel ? 0 : -1}
              onClick={() => setSelected(i)}
              className={`-mb-px border-b-2 px-4 py-2 text-sm font-semibold ${
                isSel
                  ? 'border-[color:var(--color-primary)] text-[color:var(--color-primary)]'
                  : 'border-transparent text-on-surface-variant hover:text-on-surface'
              }`}
            >
              {t.label}
            </button>
          )
        })}
      </div>
      {tabs.map((t, i) => (
        <div
          key={i}
          role="tabpanel"
          id={`${baseId}-panel-${i}`}
          aria-labelledby={`${baseId}-tab-${i}`}
          hidden={i !== selected}
          tabIndex={0}
          className="px-1 py-4 text-sm text-on-surface-variant"
        >
          {t.content}
        </div>
      ))}
    </div>
  )
}

Accessibility pitfalls

  • AvoidEvery tab is a tab stop, so Tab cycles through all of them before reaching the panel.

    DoUse a roving tabindex; the arrow keys move between tabs instead.

  • AvoidThe arrow keys do nothing.

    DoHandle Left / Right / Home / End on the tablist.

  • AvoidAutomatic activation on a panel that is expensive to render, so arrowing lags.

    DoFor heavy panels use manual activation — arrows move focus, Enter/Space selects.