Skip to content

feat(core): TabList scrolls when the tabs are wider than the strip - #5348

Draft
cixzhang wants to merge 4 commits into
mainfrom
tablist-scroll-overflow
Draft

feat(core): TabList scrolls when the tabs are wider than the strip#5348
cixzhang wants to merge 4 commits into
mainfrom
tablist-scroll-overflow

Conversation

@cixzhang

@cixzhang cixzhang commented Aug 22, 2026

Copy link
Copy Markdown
Contributor

What

A TabList narrower than its tabs now scrolls. Every tab stays a tab — nothing is hidden behind a menu — the edges fade to show there is more, and pointers that can hover get an arrow at each scrollable edge. The selected tab is scrolled back into view whenever it would be out of sight, including on mount.

One new prop, overflow:

  • 'auto' (default) — the component chooses. Today that is always scrolling.
  • 'scroll' — scroll, explicitly.
  • 'none' — the old layout: tabs keep their intrinsic widths and spill out of the strip.

Why

Today a strip too narrow for its tabs simply overflows its container: the tabs past the edge render outside the box and there is no way to reach them. The documented answer was to wrap the children in a Carousel — which works, but a carousel is a different widget. It puts a region with aria-roledescription="carousel" between the strip and its tabs and wraps each tab in a group announced as slide N of M, and it brings a layer stack, buttons and a translator along for a strip that only needs to scroll.

The behaviour belongs to the strip. This builds it on useScrollOverflow, the public hook the carousel itself uses for exactly this — 107 lines, already shared with the table's sticky columns — so there is no new measurement machinery, and nothing is inserted between the strip and its tabs. The two Carousel stories are replaced by the built-in behaviour.

Shape

The tabs move into a scroll container inside the <nav>. The nav keeps the theme target, the label, the keyboard wiring and the xstyle surface; the strip inside it is the thing that scrolls, and the arrows are its siblings rather than its children, so nothing foreign sits among the tabs.

Two details worth calling out, because both are invisible until they are wrong:

The bleed. A scroll container clips at its padding box, and a tab paints two things outside its own box: the focus ring, and the selected indicator — which drops further down when a divider rail is reserved. The strip pads by exactly that much and takes the padding straight back off with a negative margin, so nothing is clipped and the strip occupies the space it did before. Without it, a keyboard user loses the top and bottom of the focus ring.

The arrows are for pointers only. They are aria-hidden, out of the tab order, and shown only under (hover: hover). A tab strip is deliberately a single tab stop with a roving tabindex: keyboard and screen-reader users already reach every tab with the arrow keys, and focusing a tab scrolls it into view. Two more tab stops would be two more things to pass through, for an affordance those users do not need.

Risk

Scrolling is on by default. A strip that fits is untouched — same size, no fades, no arrows. A strip that does not fit changes: it scrolls instead of spilling. That is the point of the change, and overflow="none" restores the old behaviour exactly.

Trailing content composed into the strip (an action button, a TabMenu a caller passed in) scrolls with the tabs, because it is part of the same content. Giving the strip a fixed sibling slot for that is a separate change.

One shared utility moved with it: the resize-observer helper now degrades to its one-shot measurement where ResizeObserver does not exist, instead of throwing. That is what keeps a TabList rendered in a DOM-less test environment working without every consumer stubbing the API — until now nothing in this component measured anything.

Screenshots

Storybook, container fixed at 400px (350px for the divider row) so the strip cannot fit its tabs.

Light — before, the last tabs render outside the container and cannot be reached; after, the strip clips at its own edge, fades, and offers an arrow.

before after
before light after light

Dark — same behaviour; the arrow takes the popover surface colour and stays legible over the faded tabs.

before after
before dark after dark

With a divider (size="lg") — the tabs stay on the rail and the selected indicator still sits on it; nothing is clipped by the new scroll container.

before after
before divider after divider

RTL — the fade, the arrow side and the chevron all flip.

before after
before rtl after rtl

Against the Carousel recipe it replaces — the recipe (left) and the built-in behaviour (right) look the same. The difference is in the accessibility tree, not the pixels.

Carousel recipe (before) built in (after)
carousel recipe after light

Scrolled to the middle — both edges faded, both arrows:

scrolled mid

Keyboard focus on a middle tab, showing the ring drawn complete inside the scroll container:

focus ring

Keeping the selected tab visible

Three ways it could have gone out of view, and none of them is the obvious one:

  • Selection moves. The host sets value, or the strip mounts with a tab already selected. An effect scrolls it back.
  • Focus moves. The browser scrolls a focused element into view only when it is entirely outside the scrollport, so arrowing onto a tab that straddles the edge left it cut off under the fade. The strip finishes that scroll itself.
  • The strip moves. A strip that fitted at one width can hide the selected tab at a narrower one, and no prop changes when that happens — so the wrapper is observed for resize.

Focus traversal, measured

Driven in Chromium against Storybook, LTR and RTL, on the nine-tab strip (855px of tabs in a 408px box):

  • arrow-keying from the first tab to the last and back puts every one of the nine fully inside the scroll box and clear of the faded edge — in both directions, in both writing directions. Home and End likewise.
  • the strip is one tab stop: Tab enters on the roving stop, the next Tab leaves the component. The scroll arrows are not in the order, and a real pointer click on one scrolls without taking focus.
  • the focus ring is drawn complete on every stop but one (below).

Two things it turned up.

A half-visible overflow menu trigger was not scrolled into view. The reveal looked for tabs only, so the strip's other roving stop was left to the browser — which scrolls a focused element in only when it is entirely outside the scrollport. Arrowing onto a trigger straddling the edge left it cut off: 21px outside a 200px strip, with its focus ring running off the edge. Same class of bug as the one already fixed for tabs, and fixed the same way.

before after
before after

RTL, last tab, at the end of the scroll range: the ring loses 0.9px. Chrome's fractional rounding of an RTL scroll range clamps the strip a hair short of where the last tab's ring would fit, so its outer edge is clipped by 0.9px. Left alone: it is a rounding artefact of the scroll range rather than of this component, and buying it back means carrying a permanent extra pixel of bleed for a sub-pixel sliver. Everything else measured identically between the two directions.

The arrow edge

The scroll box is a ring bleed (5px) wider than the TabList on each inline side — that is what keeps the first and last tab's focus ring off the clip edge — and the fade ran out at the box's edge rather than the component's. So the strip painted tabs 5px outside itself: past a divider rail, and past the arrow that caps that edge, leaving a sliver of tab showing beyond the arrow.

The fade now reaches transparent at the component's own edge, so the arrow's outer edge, the faded edge and the TabList's box coincide. Masking the bleed costs nothing: it is only masked at an edge that is scrolled away from, and a stop at such an edge never holds focus — the reveal keeps the focused stop a fade width clear of both edges. Measured in the strip just outside the component's box, across writing direction, scroll position, both stories and both edges — eight configurations: ink up to 19/255 outside the box in five of them before, none in any of them now.

Zoomed 6×, end of the strip; the dashed line is the demo container's edge.

before after
before after

The same edge against a divider rail, where there is a line to be out of register with.

before after
before after

Should the arrows be configurable?

A recommendation, not a change — nothing here is built.

The arrows are already conditional twice over: they appear only at an edge that actually overflows, and only under (hover: hover), so a touch device never renders one. And hiding them strands nobody — arrow-key traversal moves the roving stop and scrolls it clear of the edge (measured above), and a pointer that hovers can wheel the strip. So "show them only in some circumstances" is already the behaviour, decided in the component rather than in every consumer's props.

What is left is a hard opt-out for a pointer that can hover. The smallest thing that serves it is the prop that already exists, given a sharper meaning — no second prop, no breakpoint or visibility map:

  • 'auto' (default) — the component chooses. Today: scroll, with arrows where the pointer can hover.
  • 'scroll' — scroll, plainly. No arrows.
  • 'none' — unchanged.

"No arrows" becomes one word, and any later refinement of when arrows appear stays inside 'auto', in one place, instead of turning into props. Nothing has shipped, so redefining 'scroll' costs no migration. The alternative — an arrows boolean, then a breakpoint for it — is the road OverflowList took with maxVisibleItems/maxRows, and it still left people reaching past the component.

Two notes on the automatic half. The arrows are keyed off (hover: hover) in CSS rather than a coarse-pointer media query read in JS: it is the question that actually matters for a 32px round target, and it needs no hydration pass. And a strip that fits shows nothing at all, so the common case never sees an arrow either way.

Testing

Twelve new unit tests, all failing without their fix and passing here; the 46 that already covered the strip pass unchanged, as does the rest of the package (7313 tests).

Measured in Chromium against Storybook, since none of this is decided by the markup:

  • the strip scrolls (855px of tabs in a 408px box) and clips at its own edge
  • the selected tab is brought into view on mount (scrollLeft 447, the maximum) and when the host changes value with no focus involved (447 → 0 → 447), each time landing clear of the fade rather than under it
  • arrow-keying to the last tab leaves it fully inside the scroll box
  • pressing an arrow scrolls by a page (0 → 326)
  • the focus ring is drawn complete on the first and on a middle tab — ring box 12–54px against a clip box of 12–54px
  • the accessibility tree is unchanged: navigation "Tabs" with nine buttons, and the arrows do not appear in it at all
  • arrow-keying through all nine tabs leaves every one of them fully inside the box and clear of the fade
  • narrowing the container from 1100px to 400px under a selected last tab brings it back (scrollLeft 0 → 447, selected tab ends at 415px against a clip edge of 420px)
  • clicking an arrow scrolls but leaves focus off it, so focus never rests on something hidden from assistive tech
  • RTL flips the fade, the arrow side and the chevron

Fades, arrow visibility and the scroll arithmetic are covered in jsdom by describing the scroll box by hand, the same way the carousel's own tests do.

A strip narrower than its tabs spilled out of its container and the tabs past the edge were simply unreachable. The documented answer was to wrap the children in a Carousel, which brought region and slide semantics a tab strip should not have.

TabList now owns the behaviour, built on the existing useScrollOverflow hook: overflow-x on the strip, edge fades, and pointer arrow affordances. The selected tab is scrolled back into view whenever it would be out of sight. New overflow prop: auto (default, today always scroll), scroll, none.
@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 1:31am

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
@github-actions github-actions Bot added the needs:design-review Affects visuals — Design should review label Aug 22, 2026
@github-actions

github-actions Bot commented Aug 22, 2026

Copy link
Copy Markdown
Contributor

PR Analysis Report

📚 Storybook Preview

View Storybook for this PR
GitHub Pages may take up to a minute to hydrate after deploy.

🧪 Sandbox Preview

View Sandbox for this PR
GitHub Pages may take up to a minute to hydrate after deploy.

Modified Components

TabList (@astryxdesign/core) · View in Storybook
Metric Before After Delta
Bundle Size (ESM) N/A N/A N/A
Lines of Code N/A 1062 -
Complexity N/A Very High (66) -

Bundle Size Summary

Package Size (ESM) Size (CJS) Gzipped
@astryxdesign/core N/A 4.8KB 1.2KB

Accessibility Audit

Status: 1 accessibility violation(s) found — 1 moderate.

TabList - 1 issue(s)
  • 🟡 moderate: Ensure landmarks are unique
    • Rule: landmark-unique · Affects 2/13 stories · Learn more

Generated by PR Enrichment workflow | Storybook | Sandbox | View full report

Three gaps in the scrolling strip, all in the default path:

- Chrome scrolls a focused element into view only when it is entirely outside the scrollport, so arrowing onto a tab that straddles the edge left it cut off under the fade. The strip now finishes that scroll itself.
- Selection was only re-checked when value changed, so a strip that narrowed under a selected tab left it out of view with no prop to react to. The wrapper is now observed for resize.
- Clicking a scroll arrow put focus on a button that is hidden from assistive technology.
The strip's focus reveal looked for [data-tab-value], so the TabMenu
trigger -- the strip's other roving stop -- was left out. Chrome scrolls
a focused element into view only when it is entirely outside the
scrollport, so arrowing onto a trigger that straddles the edge left it
cut off with nothing to finish the scroll: measured in Chromium at a
200px strip, the trigger stayed 21px outside the box with its focus ring
running off the edge.
The scroll box is a ring bleed wider than the TabList on each inline
side -- that is what keeps the first and last tab's focus ring from
being clipped -- so the fade ran to the bleed edge and the strip painted
tabs outside the component: past a divider rail, and past the scroll
arrow that caps that edge, which left a sliver of tab showing beyond the
arrow. Measured against the demo container in Chromium across direction,
scroll position and both edges: ink up to 19/255 outside the box in five
of eight configurations, none in any of them now.

Masking the bleed costs nothing, because it is only masked at an edge
that is scrolled away from, and a stop at such an edge never holds focus
-- the reveal keeps the focused stop a fade width clear of both edges.
The fade's opaque point and that reveal inset are the same distance for
the same reason, so they are now the same constant.
@cixzhang
cixzhang force-pushed the tablist-scroll-overflow branch from 0628b8e to 34f5c6d Compare August 23, 2026 01:28
github-actions Bot added a commit that referenced this pull request Aug 23, 2026
github-actions Bot added a commit that referenced this pull request Aug 23, 2026
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. needs:design-review Affects visuals — Design should review

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant