Skip to content

[Tabs] Fix selected tab not scrolled into view when scroll buttons appear (auto mode)#48489

Open
starboyvarun wants to merge 4 commits into
mui:masterfrom
starboyvarun:44211-tabs-scroll-into-view
Open

[Tabs] Fix selected tab not scrolled into view when scroll buttons appear (auto mode)#48489
starboyvarun wants to merge 4 commits into
mui:masterfrom
starboyvarun:44211-tabs-scroll-into-view

Conversation

@starboyvarun
Copy link
Copy Markdown
Contributor

Fixes #44211

Problem

When variant="scrollable" and scrollButtons="auto", the IntersectionObserver fires asynchronously after the initial render to determine whether the first/last tabs are visible. When it fires, React shows the scroll buttons, which shrinks the scroller.

The scrollSelectedIntoView function is only triggered when indicatorStyle changes. But when the scroller shrinks (scroll buttons appear), the selected tab's indicator position relative to the scroller does not change — the tab and the scroller both shift by the same amount. So indicatorStyle stays the same, scrollSelectedIntoView is never called, and the selected tab ends up partially out of view.

This was the root cause behind the reverted PR #46869. That fix re-ran scrollSelectedIntoView whenever displayStartScroll/displayEndScroll changed — but this also fired when scroll buttons were clicked (causing the view to snap back to the selected tab instead of scrolling per the button click) and caused animation on initial render.

Solution

Add a ResizeObserver on the scroller element (tabsRef.current) that calls scrollSelectedIntoView(false) (no animation) whenever the scroller's size changes.

  • Fires when scroll buttons appear/hide — the scroller shrinks/grows in the flex layout, triggering the observer. ✅
  • Does NOT fire when scroll buttons are clicked — clicking scrolls the content, not the scroller's size. ✅
  • Does NOT animate on initial mountscrollSelectedIntoView(false) is passed animation = false. ✅
  • Cleans up with scrollerResizeObserver?.disconnect() on unmount. ✅

Changes

  • Tabs.js — add scrollerResizeObserver that observes the scroller and calls scrollSelectedIntoView(false) on resize; add scrollable, scrollButtons, scrollSelectedIntoView to the useEffect dep array
  • Tabs.test.js — add test: mock ResizeObserver, trigger the scroller callback, verify scrollLeft is corrected

…pear in auto mode

When scrollButtons="auto", the IntersectionObserver fires asynchronously after
the initial render to show scroll buttons. This shrinks the scroller, which can
push the selected tab out of view. Since the tab's indicatorStyle doesn't change
when the scroller resizes, scrollSelectedIntoView was never re-triggered.

Add a ResizeObserver on the scroller element that calls scrollSelectedIntoView
(without animation) whenever the scroller's size changes. This covers the
scroll-buttons-appear case on initial mount without introducing the side effects
of the reverted PR mui#46869 (animating on mount, re-scrolling on button clicks).

Fixes mui#44211
@code-infra-dashboard
Copy link
Copy Markdown

code-infra-dashboard Bot commented May 6, 2026

Deploy preview

https://deploy-preview-48489--material-ui.netlify.app/

Bundle size

Bundle Parsed size Gzip size
@mui/material 🔺+111B(+0.02%) 🔺+29B(+0.02%)
@mui/lab 0B(0.00%) 0B(0.00%)
@mui/private-theming 0B(0.00%) 0B(0.00%)
@mui/system 0B(0.00%) 0B(0.00%)
@mui/utils 0B(0.00%) 0B(0.00%)

Details of bundle changes


Check out the code infra dashboard for more information about this PR.

@zannager zannager added the scope: tabs Changes related to the tabs. label May 7, 2026
@zannager zannager requested a review from ZeeshanTamboli May 7, 2026 12:34
@mj12albert
Copy link
Copy Markdown
Member

This fix doesn't seem correct yet, the failing tests in CI look directly related

The ResizeObserver on the scroller was attached for any scrollButtons !== false,
which included scrollButtons=true. In real browsers the observer fires an initial
entry after observation starts, calling scrollSelectedIntoView unexpectedly and
breaking existing browser tests. The scroller only resizes asynchronously (due to
IntersectionObserver toggling scroll buttons) in auto mode, so scope the observer
to scrollButtons === 'auto'.
@starboyvarun
Copy link
Copy Markdown
Contributor Author

starboyvarun commented May 11, 2026

@mj12albert
Found the cause of the browser test failures. The condition was scrollButtons !== false, which created a scrollerResizeObserver for scrollButtons=true as well as 'auto'. In real browsers, a ResizeObserver fires an initial entry immediately after observe() is called — so those existing tests with scrollButtons (implicitly true) were getting an unexpected scrollSelectedIntoView call, which corrupted their scrollLeft assertions.

The scroller only resizes asynchronously (when the IntersectionObserver toggles scroll button visibility) in 'auto' mode. With scrollButtons=true buttons are always visible, so the scroller width is stable after mount. Changed the condition to scrollButtons === 'auto'.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

scope: tabs Changes related to the tabs.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Tabs not scrolling to the correct tab after navigation with scrollable="auto"

3 participants