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