Skip to content

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

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

feat(core): TabList scrolls when the tabs are wider than the strip#5348
cixzhang wants to merge 5 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

Thirteen 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 (7314 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.

The strip's own footprint

The scroll box is a ring bleed (5px) wider than the TabList on each side — padding-inline taken straight back off with a negative margin — so the ring of a tab flush with an edge is not clipped by the new scroll container. That padding is real geometry: the strip's border box sticks out of the TabList's, and any ancestor with a scroll axis counts it. Measured in a 400px overflow: auto host, on every TabList and not only an overflowing one:

inline scrollWidth/clientWidth block scrollHeight/clientHeight
before the strip could scroll 400/400 33/32
with the bleed 405/400 37/32
now 400/400 33/32

Same numbers in Chromium and in WebKit. Invisible in most layouts; a flush-edge one gets a scrollbar for 5px of nothing.

A bleed only earns its keep while there is a ring to keep whole, so it is now taken only then: the padding and its cancelling margin read a variable that is 0px at rest and the ring's reach under :has(:focus-visible) — the same condition that draws the ring, so a pointer click costs nothing (measured: 400/400 after a click, 405/400 after the arrow key that first draws a ring, in both engines). Padding and negative margin cancel, so turning it on moves nothing — the border box grows outwards and the tabs stay put. The selected indicator's own 1px share stays unconditional, because the indicator is always drawn; that 1px is the strip's whole footprint at rest, and it is what the component had before it could scroll.

The scroll strip's box in orange, the TabList's own box in blue, at 6×, at the end edge.

A TabList whose tabs fit — nothing is scrolling, and it was still 5px wider than its slot on every side:

before after
before after

The overflowing strip, at the arrow that caps the end edge:

before after
before after

And the thing the bleed exists for, unchanged — the first tab's focus ring at the start edge is pixel-identical, before and after, in all eight configurations (story × direction × edge):

before after
before after

What is not fixed, and why nothing else would be

While a ring is drawn the strip takes its 5px back, and an ancestor sees 405/400 again for as long as the ring stays up. That is disclosed rather than fixed, because a scroll container cannot paint outside itself: the scrollport clips at its padding box, so the only way an edge-flush stop's ring survives is for the scrollport to be wider than the component — and a wider box is exactly what an ancestor counts.

A clip root around the scroller, the shape Carousel uses, does not buy a way out. Measured, at a 400px overflow: auto ancestor, with a child whose border box sticks out 5px:

root ancestor sees ring outside the root
overflow: visible 405/400 drawn
overflow: clip 400/400 clipped
overflow: clip; overflow-clip-margin: 2px 402/400 clipped
overflow: clip; overflow-clip-margin: 5px 405/400 drawn

In Chromium the clip margin grants ink and leaks scrollable width in equal measure — the ancestor sees min(the child's overflow, the clip margin) — so a margin wide enough for the ring leaks the whole bleed. A child that fits and only paints outside itself (an outline) escapes a 5px clip margin for free, at 400/400, which is the same rule read the other way: it is the box that leaks, not the ink. And overflow-clip-margin does nothing on a scroll container, so the scroller cannot let its own ring out either. WebKit does not implement overflow-clip-margin at all (CSS.supports is false in Safari 26.5), so there overflow: clip is plain clip and the ring is cut flat at the edge.

Carousel is unaffected either way: its scroller carries only a 1px block-end bleed, and its 1px clip margin leaks exactly that 1px (65/64 at the same ancestor). A TabList nested inside a Carousel never showed the defect — 400/400 before and after — because the Carousel's own scroll container absorbs the strip's bleed before any outer ancestor sees it.

Re-checked

  • the eight-configuration edge matrix (story × direction × scroll position × edge): no ink outside the TabList's box in any of them, before or after
  • the focus ring at both inline edges, in both stories, in both directions: pixel-identical to the previous round
  • nothing moves when focus lands, at either end of the scroll range, in Chromium and WebKit

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 6:15pm

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 1068 -
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.
A scroll container clips at its padding box, so the strip pads by the
focus ring's reach and takes the padding back off with a negative
margin. That padding is real geometry: the strip's border box sticks out
of the TabList's by 5px on every side, and any ancestor with a scroll
axis counts it. Measured in a 400px `overflow: auto` host, on every
TabList and not only an overflowing one: 405/400 across the inline axis
and 37/32 down the block axis, against 400/400 and 33/32 before the
strip could scroll. Invisible in most layouts; a flush-edge one gets a
scrollbar for 5px of nothing.

The bleed only earns its keep while there is a ring to keep whole, so it
is now taken only then: the padding and its cancelling margin read a
variable that is zero at rest and the ring's reach under the same
`:has(:focus-visible)` condition that draws the ring, so a pointer click
-- which draws none -- costs nothing. Padding and negative margin
cancel, so turning it on moves nothing: the border box grows outwards
and the tabs stay put. The selected indicator's own share stays
unconditional, because the indicator is always drawn.

At rest the strip is now exactly as wide and as tall as the TabList, in
Chromium and in WebKit alike: 400/400 and 33/32, matching the component
before it could scroll. While a ring is drawn the strip takes its 5px
back and the ancestor sees 405/400 again -- disclosed rather than fixed,
because a scroll container cannot paint a ring outside itself: the
scrollport has to be wider than the component for the ring to survive,
and a clip root does not help. `overflow-clip-margin` grants ink and
leaks scrollable width in equal measure in Chromium, and WebKit does not
implement it at all.

The eight-configuration edge matrix is unchanged (no ink outside the box
at any edge, direction or scroll position), the focus ring measures
pixel-identical in all eight, and a TabList nested in a Carousel is
unaffected either way.
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