feat(core): TabList scrolls when the tabs are wider than the strip - #5348
Draft
cixzhang wants to merge 5 commits into
Draft
feat(core): TabList scrolls when the tabs are wider than the strip#5348cixzhang wants to merge 5 commits into
cixzhang wants to merge 5 commits into
Conversation
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.
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
github-actions
Bot
requested review from
cvkxx,
ernestt,
kentonquatman and
rubyycheung
August 22, 2026 21:57
Contributor
PR Analysis Report📚 Storybook PreviewView Storybook for this PR 🧪 Sandbox PreviewView Sandbox for this PR Modified ComponentsTabList (@astryxdesign/core) · View in Storybook
Bundle Size Summary
Accessibility AuditStatus: 1 accessibility violation(s) found — 1 moderate. TabList - 1 issue(s)
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.
cixzhang
force-pushed
the
tablist-scroll-overflow
branch
2 times, most recently
from
August 23, 2026 01:13
58af3bc to
0628b8e
Compare
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
force-pushed
the
tablist-scroll-overflow
branch
from
August 23, 2026 01:28
0628b8e to
34f5c6d
Compare
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.
cixzhang
force-pushed
the
tablist-scroll-overflow
branch
from
August 23, 2026 18:12
b384572 to
7b025fa
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
A
TabListnarrower 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 aregionwitharia-roledescription="carousel"between the strip and its tabs and wraps each tab in agroupannounced 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 twoCarouselstories 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 thexstylesurface; 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
TabMenua 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
ResizeObserverdoes not exist, instead of throwing. That is what keeps aTabListrendered 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.
Dark — same behaviour; the arrow takes the popover surface colour and stays legible over the faded tabs.
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.RTL — the fade, the arrow side and the chevron all flip.
Against the
Carouselrecipe it replaces — the recipe (left) and the built-in behaviour (right) look the same. The difference is in the accessibility tree, not the pixels.Scrolled to the middle — both edges faded, both arrows:
Keyboard focus on a middle tab, showing the ring drawn complete inside the scroll container:
Keeping the selected tab visible
Three ways it could have gone out of view, and none of them is the obvious one:
value, or the strip mounts with a tab already selected. An effect scrolls it back.Focus traversal, measured
Driven in Chromium against Storybook, LTR and RTL, on the nine-tab strip (855px of tabs in a 408px box):
HomeandEndlikewise.Tabenters on the roving stop, the nextTableaves the component. The scroll arrows are not in the order, and a real pointer click on one scrolls without taking focus.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.
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.
The same edge against a divider rail, where there is a line to be out of register with.
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 — anarrowsboolean, then a breakpoint for it — is the roadOverflowListtook withmaxVisibleItems/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:
scrollLeft447, the maximum) and when the host changesvaluewith no focus involved (447 → 0 → 447), each time landing clear of the fade rather than under itnavigation "Tabs"with nine buttons, and the arrows do not appear in it at allscrollLeft0 → 447, selected tab ends at 415px against a clip edge of 420px)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-inlinetaken 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 400pxoverflow: autohost, on every TabList and not only an overflowing one:scrollWidth/clientWidthscrollHeight/clientHeightSame 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
0pxat 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:
The overflowing strip, at the arrow that caps the end edge:
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):
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
Carouseluses, does not buy a way out. Measured, at a 400pxoverflow: autoancestor, with a child whose border box sticks out 5px:overflow: visibleoverflow: clipoverflow: clip; overflow-clip-margin: 2pxoverflow: clip; overflow-clip-margin: 5pxIn 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. Andoverflow-clip-margindoes nothing on a scroll container, so the scroller cannot let its own ring out either. WebKit does not implementoverflow-clip-marginat all (CSS.supportsis false in Safari 26.5), so thereoverflow: clipis plainclipand the ring is cut flat at the edge.Carouselis 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