Skip to main content
ASoc
Tutorial

React Tabs: Six Requirements, and the Three Ours Was Missing

Our dashboard tabs had the roles and none of the wiring — no aria-controls, no tabpanel, no roving tabindex. The audit, why no scanner caught it, and the fix that shipped with this post.

The ASoc Team12 min read

A React tab component is not a group of buttons that swaps a <div>. It is a six-part contract: role="tablist", role="tab", aria-selected, aria-controls, a matching role="tabpanel" with aria-labelledby, and a roving tabindex so the whole strip is one keyboard stop. Our own dashboard had three of the six. Here is the audit, and the fix that shipped with this post.

The tabs in this codebase live at src/components/organisms/DashboardTabs.tsx — the three-way switch on /dashboard between Overview, Purchases & Downloads, and Settings. It looked finished. It rendered correctly, it was keyboard-reachable, and no tool in our pipeline had ever complained about it. It was still wrong, and the reason it stayed wrong for so long is the more interesting half of this post.

What we had, and what the pattern actually requires

RequirementWhy it existsWas it there?
role="tablist" on the containerTells AT this is a tab set, not a toolbar
aria-label on the tablistNames the set — "Dashboard sections"
role="tab" on each controlAnnounces "tab" instead of "button"
aria-selected on each controlAnnounces which one is current
id on each tabThe anchor aria-labelledby points back at
aria-controls on each tabWires the tab to the region it governs
role="tabpanel" + aria-labelledby on the contentMakes the content a named region, not a loose <div>
Roving tabindex (0 on active, -1 on the rest)One tab stop for the strip, not one per tab
Arrow / Home / End within the stripHow you move between tabs once you are in the strip

Four of nine rows, and the four missing ones are not decoration. role="tab" without aria-controls and a role="tabpanel" is arguably worse than no roles at all: a screen reader announces "Purchases & Downloads, tab, 2 of 3, selected" and then has nothing to hand the user when they ask for the associated region, because as far as the accessibility tree is concerned there isn't one. You have promised a relationship the markup does not contain.

Why nothing caught it

This is the part worth generalising from. This site has been audited hard — the Lighthouse pass documented in docs/superpowers/LIGHTHOUSE.md took eight pages to accessibility 100 on desktop and mobile, and found three real defects doing it (/blog had no <h1>, /docs skipped h1 → h3, and a tag chip missed AA contrast by 0.01).

/dashboard was not one of the eight. It could not be. Look at the route table from a production build of this repo:

├ ○ /docs
├ ƒ /dashboard
├ ƒ /dashboard/settings

ƒ means server-rendered on demand. The dashboard reads a Supabase session, so it is dynamic, noindex, and behind a login — an automated audit pointed at a URL cannot reach it, and the Lighthouse doc says so explicitly. Every page a crawler can see was measured. The page where the actual ARIA defect lived was the page no crawler can see.

There is a second reason, and it is one people trip over constantly: axe-core would not have flagged most of this either. The automated rules check that a role="tab" sits inside a role="tablist", and that required ARIA attributes are present. aria-controls is not a required attribute of role="tab" in the ARIA spec — it is required by the authoring practices, which is guidance, not schema. A missing roving tabindex is a behaviour, and scanners do not press keys. We wrote a whole post about this gap — a Lighthouse accessibility score of 100 is not WCAG conformance — and then shipped a live example of it three routes away.

The fix

Roles and wiring first. Every tab gets an id and an aria-controls; the panel becomes a real region that names itself after the tab that governs it:

// src/components/organisms/DashboardTabs.tsx
<button
  key={tab.id}
  ref={(el) => {
    if (el) tabRefs.current[tab.id] = el;
    else delete tabRefs.current[tab.id];
  }}
  type="button"
  role="tab"
  id={`dashboard-tab-${tab.id}`}
  aria-selected={activeTab === tab.id}
  aria-controls={`dashboard-panel-${tab.id}`}
  tabIndex={activeTab === tab.id ? 0 : -1}
  onClick={() => select(tab.id)}
>
  {tab.label}
</button>
<div
  role="tabpanel"
  id={`dashboard-panel-${activeTab}`}
  aria-labelledby={`dashboard-tab-${activeTab}`}
>
  {content[activeTab]}
</div>

Then the keyboard. tabIndex={activeTab === tab.id ? 0 : -1} is the roving tabindex: Tab now enters the strip once, at whichever tab is selected, and the next Tab leaves it entirely. That is the whole point — a tablist is one control, and a ten-tab strip should not cost ten presses to walk past. Movement inside the strip is arrow keys, handled once on the container rather than on each button:

const onKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
  const current = TABS.findIndex((t) => t.id === activeTab);
  let next: number;
  if (e.key === "ArrowRight") next = (current + 1) % TABS.length;
  else if (e.key === "ArrowLeft")
    next = (current - 1 + TABS.length) % TABS.length;
  else if (e.key === "Home") next = 0;
  else if (e.key === "End") next = TABS.length - 1;
  else return;
  e.preventDefault();
  const { id } = TABS[next];
  select(id);
  // Focus has to move with selection, or the roving tabindex leaves the
  // browser focused on a button that is now `tabIndex={-1}`.
  tabRefs.current[id]?.focus();
};

That comment is the bug this pattern hands you if you implement half of it. Change activeTab without moving DOM focus and the element the browser is focused on becomes tabIndex={-1} under it. Focus does not disappear, but the next Tab resumes from a stale position and the arrow keys stop responding, because the keydown is no longer landing where you think. Roving tabindex and imperative focus are one feature, not two.

The double modulo on ArrowLeft is the same trick our carousel needs: (current - 1 + TABS.length) % TABS.length, because -1 % 3 is -1 in JavaScript, not 2.

Selection follows focus, and why that is safe here

There are two legal activation modes. Manual: arrows move focus, Enter or Space selects. Automatic: arrows move focus and select in one motion. The rule is not stylistic — automatic activation is correct only when displaying a panel is instant, because otherwise arrowing across four tabs fires four loads.

Ours is instant, and that is a property of how the page is built rather than a claim about our JavaScript. From the component's own docblock:

All three tabs' content is fetched and rendered server-side up front (dashboard/page.tsx) and passed in as props — this component only toggles which one is visible, so switching tabs never re-fetches or shows a loading state.

The three panels arrive as ReactNode props from an async Server Component that has already done the Supabase queries. Switching tabs is a useState write over content that is already in the DOM tree. Automatic activation is free. If your panels fetch on select, use manual activation — the accessibility pattern and the data-fetching architecture are the same decision viewed twice.

The tabIndex on the panel that we deliberately left off

Most tutorials put tabIndex={0} on the tabpanel. The authoring practices say to do that when the panel contains no focusable elements — otherwise a keyboard user tabbing out of the strip would land nowhere. All three of ours contain links, buttons, or a form, so adding it inserts a tab stop that announces an empty region on the way to content the user can already reach. We left it off and wrote down why, because "the example had it" is how a copied tabIndex outlives the reason for it.

When it should not be tabs at all

Two other controls in this codebase look like tabs and are deliberately not:

ControlFilePattern usedWhy not tabs
Edition chips on a product pagemolecules/EditionPicker.tsxaria-pressed toggle buttonsThey select a variant of one thing, not a view of a set. Nothing is a "panel".
Category filter on the dashboard gridorganisms/YourProductsGrid.tsxPlain buttons over a filtered listThe grid is one region whose contents narrow; there is no second panel to switch to.
URL-driven category filtersTemplatesExplorer + searchParamsLinks / query stateFilters belong in the URL — see why filters belong in searchParams.

The test is simple: tabs switch between peer regions of content; anything that narrows or reconfigures one region is a filter or a toggle. Reaching for role="tab" because the design has a row of underlined labels is how you end up promising a tabpanel that does not exist — which is exactly the state our dashboard was in.

Deep-linking without breaking the pattern

One thing the dashboard does that most tab tutorials skip: the active tab is in the URL, so a support reply can link someone straight to ?tab=purchases.

const select = (id: DashboardTab) => {
  setActiveTab(id);
  router.replace(`/dashboard?tab=${id}`, { scroll: false });
};

replace rather than push so three tab clicks do not put three entries in the back stack, and { scroll: false } so the viewport stays where the user was reading. React state stays the source of truth for what renders — the URL is a mirror, written after the fact. Reading the URL as the source of truth instead would make every tab switch a navigation, which is what makes some tab implementations feel sluggish for no reason. The server side reads it once, on entry:

const { verification, tab } = await searchParams;
// ...
<DashboardTabs initialTab={isValidTab(tab) ? tab : "overview"} ... />

isValidTab is a type guard, so ?tab=nonsense falls back to Overview instead of rendering undefined.

Common mistakes

MistakeSymptomFix
role="tab" with no role="tabpanel"Screen reader announces a tab governing nothingAdd aria-controls + a panel with aria-labelledby
Every tab left in the tab orderA 10-tab strip costs 10 Tab presses to passRoving tabindex: 0 on active, -1 on the rest
Roving tabindex without imperative focusArrows appear to work once, then stopref.focus() on the newly selected tab
(i - 1) % n for the left arrowArrowLeft on the first tab goes nowhere((i - 1) + n) % n
Automatic activation over panels that fetchArrowing across tabs fires a request per tabManual activation: arrows move focus, Enter selects
tabIndex={0} on a panel full of linksAn extra tab stop on an empty regionOnly when the panel has no focusable content
router.push per tab clickBack button walks through tab historyrouter.replace(..., { scroll: false })
Trusting a scanner to catch thisLighthouse 100, pattern still brokenKeyboard-test it: Tab in, arrows across, Tab out
Using tabs for filtersA "panel" that is really the same region, narrowedaria-pressed toggles, or filters in the URL

Frequently asked questions

Do I need a library like Radix, Headless UI or react-tabs? For a fixed set of pre-rendered panels, the pattern above is about sixty lines and has no dependency. Reach for a library when you need what they solve properly and tediously: overflow with scroll buttons, drag-to-reorder, closeable tabs, or vertical orientation with the extra aria-orientation and arrow remapping that implies. We measure that trade-off across the whole codebase in when hand-rolling 91 components actually wins.

Should tabs be <button> or <a>? <button> when the panels are already in the page and switching is a UI state change, which is this case. <a href> when each "tab" is a real route with its own URL and server render — but then it is navigation, and the correct markup is a <nav> with aria-current="page", not role="tablist". Mixing the two is the most common tabs mistake after the missing panel.

How do I test this without a screen reader? Unplug the mouse. Tab should enter the strip once, landing on the selected tab. ArrowRight should move and switch panels. End should jump to the last tab. The next Tab should leave the strip entirely and land on the first focusable thing in the panel. If any of those four is wrong, the roles are decoration.

Does React 19 change any of this? Only ergonomically. Ref callbacks may now return a cleanup function, so a concise-body callback that happens to return something — (el) => tabRefs.current[id] = el returns the assigned element — is no longer a safe shorthand. The one above uses a statement body for that reason. The ARIA contract itself is framework-independent and has not moved.

Templates where these tabs already ship

ASoc Scholar is the largest of our admin builds — 13 dashboards across 210+ routed pages, with a component reference where tabbed panels appear throughout. ASoc Lura spans 11 dashboards and roughly 177 pages including a workspace suite (to-do, calendar, chat, email, kanban, file manager) where tab strips do the heavy lifting. ASoc Estate is the tighter, real-estate-focused option if you want the pattern without the surface area.

Browse the full sets: React admin templates, Next.js admin templates, Tailwind admin templates. For the wider accessibility argument this post is an instance of, read why a Lighthouse 100 is not conformance, and for the same roles-plus-keyboard treatment applied to navigation, an accessible mega menu without a headless UI library.

Keep reading

Tutorial10 min read

React Toast Notifications: Five Live Regions That Announced Nothing

Zero toast libraries and five aria-live regions here — all five mounted with their first message, so none of them ever announced. The audit, and the atom that fixed it.

Read more
Tutorial9 min read

React Tooltip: When 38 aria-label Attributes Beat a Library

38 aria-label attributes, zero tooltip libraries. What this codebase actually uses to name icon-only buttons, and the one place a real tooltip earns its keep.

Read more
Tutorial7 min read

React UI Libraries: When Hand-Rolling 91 Components Actually Wins

This storefront ships zero UI-library dependencies across 91 hand-rolled components. The real tradeoff — bundle cost against accessibility coverage you skip rebuilding — measured against our own code.

Read more