Skip to content

feat(core): TabList reads its role and speaks the ARIA tabs pattern - #5349

Draft
cixzhang wants to merge 1 commit into
tablist-scroll-overflowfrom
tablist-mode
Draft

feat(core): TabList reads its role and speaks the ARIA tabs pattern#5349
cixzhang wants to merge 1 commit into
tablist-scroll-overflowfrom
tablist-mode

Conversation

@cixzhang

@cixzhang cixzhang commented Aug 22, 2026

Copy link
Copy Markdown
Contributor

What

TabList can speak the WAI-ARIA tabs pattern instead of the navigation one — and there is no new prop for it. TabList declares role?: AriaRole and reads it, the way LayoutHeader, LayoutContent and LayoutPanel already declare and document theirs:

role what the strip is
"tablist" the tabs pattern, asserted by the caller: role="tablist" on the strip, role="tab" and aria-selected on the tabs, aria-controls from a new panelId on Tab
unset the strip picks. Tabs when it holds nothing but tabs; a <nav> landmark with aria-current when it holds anything else
anything else passed through to the element, untouched, exactly as before

Stacked on #5348 — review that one first; this branch contains it.

Why no new prop

role already reaches the DOM: {...restProps} spreads it onto the wrapper, so a caller can pass role="tablist" today and get a tablist whose children are still <button>s with aria-current — invalid markup, no aria-selected, nav-style arrow keys, and no warning. Reading the role turns that silent breakage into the correct behaviour, and declaring it explicitly is what puts it in the type, the prop table and the docs, where a behaviour keyed on an anonymous passthrough would be invisible.

This changes the default, and that is the risky part

A strip of plain tabs is a <nav> landmark today and becomes a tablist. Concretely, for a caller who changes nothing:

  • the selected tab is announced as a selected tab (aria-selected) rather than the current item (aria-current)
  • the navigation landmark goes away — the element stays a <nav>, with role="none"
  • ArrowUp and ArrowDown stop moving between tabs and scroll the page instead, because a tablist reports itself as horizontal. ArrowLeft, ArrowRight, Home, End, Tab and the roving tab stop are unchanged
  • nothing about the rendering changes: same element, same classes, same pixels

A strip that really is page navigation keeps the landmark by saying so: role="navigation". In this repo the change moved 20 existing tests, which is the honest measure of the blast radius — the changeset is filed [breaking] for that reason.

Two things the strip will not do to you on its own

A tab with an href keeps navigating. An href is only ignored where role="tablist" was asked for explicitly, and then a development warning says so. Left to pick, a strip holding a link stays navigation — the component choosing a pattern is not licence to break a caller's link, and it is the one case where the default change could have been a functional regression rather than a semantic one.

A tab that controls nothing is only worth mentioning to someone who asked for the pattern. That warning is likewise limited to the explicit role: a strip the component made a tablist out of tabs written for navigation has done nothing wrong, and a warning that fires on a correct setup is worse than a missing one. Either panelId or an aria-controls you wrote yourself satisfies it — Tab already spreads yours to the DOM, so both spellings count and a hand-written one is never overwritten. Nothing validates that the target exists: a panel may mount later.

A panelId on a strip that turns out not to be a tablist is dropped without a word, deliberately: the pattern settles in a layout effect, after a tab's own effects have run, so a tab cannot tell "not a tablist" from "not a tablist yet" — measured, a warning there fires on every correct strip on mount. The prop doc says so instead.

aria-controls is emitted only when a panelId (or your own aria-controls) is supplied. Generating one would ship a dangling reference, which axe rates critical (aria-valid-attr-value); measured in Chromium on three hand-built tablists, dangling → 1 critical violation, resolved → clean, absent → clean.

The pick is made from the rendered DOM, and it settles

It reads the strip's rendered children, not children: a menu behind a conditional, inside a .map, or wrapped in a component of your own is invisible to introspection, and a check that can be walked around silently is worse than none. So it has to run in production, and it has to be cheap and stable. Measured in Chromium:

what it reads two attributes per child. No geometry, so no layout and no style recalc
cost of one check 0.6 µs for a three-tab strip (100k iterations)
what it observes one MutationObserver on the strip, {childList: true} — no subtree, no attributes
on mount 1 observer callback, 1 extra render, and the first painted frame is already correct — the first animation frame in which the strip exists reads role="tablist", so there is no flash of the other pattern
per change of contents 2 callbacks, 1 re-render, ~0 ms
while idle 0 callbacks, 0 renders

It cannot feed itself: the markers it reads — data-tab-value on a tab, the href a link tab renders — are the same whichever pattern wins, so the answer is a pure function of the child set and the second callback in a flip recomputes the same value and bails. childList alone is sufficient because neither marker can appear or disappear in place: a tab that gains an href becomes an anchor, which is an element swap.

And the wrapper stays a <nav> in both patterns, dropping its landmark with role="none" rather than becoming a <div>. That is the difference between changing an attribute and remounting the whole subtree, which would throw away focus, scroll position and every child's state each time the strip's contents change. <nav role="none"> is axe-clean and leaves no landmark and no extra node in the tree — it reads better than the <div> it replaces, which contributed a generic.

Accessibility trees, from Chromium

No role, only tabs — the new default:

tablist "Tabs"
  tab "Home"     [selected]
  tab "Projects"
  tab "Settings"

No role, a menu in the strip:

navigation "Tabs"
  button "Home" [current]
  button "Projects"
  button "More"

role="tablist":

tablist  "Project views"
  tab    "Overview"  [selected controls=panel-overview]
  tab    "Activity"
  tab    "Members"
tabpanel "Overview"

role="navigation":

navigation "Project sections"
  button "Overview" [current]
  button "Activity"
  button "Members"

axe: no violations on any of the four.

Testing

24 tests across the pattern, nine of which fail on the parent branch and pass here; the rest are regression guards that must pass on both, and do. They cover: the role being read, the pick going both ways at runtime (and the tab keeping its DOM identity across it), other roles passing through, both spellings of the panel link, and the warning when neither is there.

Keyboard, driven for real in Chromium: Tab enters the strip onto the selected tab; ArrowRight moves; Enter selects and reveals the panel; Tab leaves the strip and lands on it. ArrowDown scrolls the page from inside a tablist and still moves between items under role="navigation".

The repo's own accessibility audit, against a built Storybook: 0 new violations, and 3 baseline entries resolvedlandmark-unique on the TabList, Toolbar and edge-compensation stories, where several strips were navigation landmarks all named "Tabs" on one page. Those entries are removed from the baseline here. One landmark-unique entry remains, on the story whose strips hold action buttons and therefore stay navigation.

Full packages/core suite, typecheck and lint are green. (DateInputTouch and one SideNav test fail under the full parallel run on the parent branch too, and pass in isolation — pre-existing flakiness, untouched by this branch.)

A note on scope for the axe runs: they are scoped to the story root. At whole-document scope axe also reports its region rule, because a story is not inside a landmark — the repo's own accessibility audit disables that rule for exactly this reason.

@vercel

vercel Bot commented Aug 22, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
astryx Ready Ready Preview Aug 23, 2026 2:55am

Request Review

@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Meta Open Source bot. label Aug 22, 2026
@cixzhang
cixzhang force-pushed the tablist-scroll-overflow branch 2 times, most recently from 0628b8e to 34f5c6d Compare August 23, 2026 01:28
@cixzhang cixzhang changed the title feat(core): TabList can speak the ARIA tabs pattern feat(core): TabList reads its role and speaks the ARIA tabs pattern Aug 23, 2026
The strip could only be a nav landmark with aria-current, so a consumer with real panels below it had to reimplement it to get role=tablist, aria-selected and aria-controls.

No new prop: TabList declares role?: AriaRole and reads it, the way LayoutHeader and LayoutPanel already declare theirs. role="tablist" asks for the tabs pattern, any other role passes through untouched, and left unset the strip picks from what it renders — tabs when it holds nothing but tabs, the nav landmark when it holds a menu, a link, or anything else. That changes the default for a strip of plain tabs.

The pick reads the rendered DOM rather than children, so a menu behind a conditional, inside a map, or wrapped in a consumer's own component counts, and it follows the strip if the contents change later. It settles because the markers it reads do not depend on the answer, and the wrapper stays a <nav> — role="none" takes the landmark away — so a change of pattern never remounts the tabs.

An href is only ignored where the role was asserted, and so is the warning for a tab that controls nothing; either panelId or a hand-written aria-controls satisfies it, and a hand-written one is never overwritten.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA Signed This label is managed by the Meta Open Source bot.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant