React scrollable tabs that scroll like the browser
A tab strip stops fitting the moment your product grows past six tabs. The fix is not a smaller font — it is a strip that scrolls: overflow rides the browser, clicking a tab centers it, and arrows show up only when there is somewhere to go.
Click a tab near the edge — it scrolls itself to the center.
Edit this example live in Storybook
The one behavior that matters: center on select
A scrollable tab strip lives or dies on what happens when you click a tab at the edge: it should glide to the middle, revealing its neighbors on both sides. That is one call here — scrollToItem(el, 'smooth', 'center') — wired in the center-on-click example. Restoring the active tab on mount is the same call with 'auto', shown in save & restore position.
The arrows come from the same visibility data: useLeftArrowVisible is false only while the first tab is off screen, so the left arrow renders exactly when it is useful. No measurement code, no resize observers of your own.
If you are outgrowing MUI scrollable tabs
Material UI's variant="scrollable" tabs are the right answer inside Material's design system — until your "tabs" stop being tabs. MUI welds the strip to Tabs semantics: a value/onChange pair, tab panels, and scroll buttons that MUI hides on mobile by default. The moment your row holds chips, cards, avatars or mixed content, or needs drag-to-scroll, or needs to know which items are visible, you are fighting the component rather than using it.
This library is the layer below that: a scrolling row with visibility tracking, no opinion about what a "tab" is. Your tab is any component with an itemId — style it with Tailwind, MUI's own styled, or plain CSS. Selection state stays yours, exactly like the demo above keeps it in one useState.
Accessibility is mostly free — mind the two gaps
Because the strip is a native scroll container, keyboard focus, screen-reader reading order and RTL come from the platform — moving focus through tabs scrolls them into view with zero code, and RTL needs no configuration. Two things stay on you, same as any tab UI: pick your ARIA pattern (role="tablist" if real panels switch, aria-current if the "tabs" are navigation), and keep the drag-to-scroll recipe's click suppression so a drag release never activates a tab.
The pattern, minimal
Tabs are plain buttons with an itemId; selecting one centers it. This is the whole idea — the demo above adds styling and drag.
function ScrollTabs({ tabs }: { tabs: string[] }) {
const [active, setActive] = React.useState(tabs[0]);
return (
<ScrollMenu LeftArrow={LeftArrow} RightArrow={RightArrow}>
{tabs.map((tab) => (
<Tab itemId={tab} key={tab} label={tab}
active={tab === active} onSelect={() => setActive(tab)} />
))}
</ScrollMenu>
);
}
function Tab({ itemId, label, active, onSelect }: TabProps) {
const api = React.useContext<publicApiType>(VisibilityContext);
return (
<button
aria-current={active}
onClick={(ev) => {
onSelect();
// The behavior that makes tabs feel native: selecting an
// edge tab glides it to the center of the strip.
api.scrollToItem(ev.currentTarget, 'smooth', 'center');
}}
>
{label}
</button>
);
}Or install it as a shadcn component
The scroll-tabs registry item ships this pattern data-driven — pass tabs, value, onValueChange — as an editable component in your components/ui/:
npx shadcn@latest add https://react-horizontal-scrolling-menu.dev/r/scroll-tabs.json