Scrollable tabs beyond MUI
value/onChange contract — and swaps the strip underneath: native scrolling, a selection that centers itself, tabs that can hold anything.Click a tab near either edge — it centers itself. Drag the row, like on a phone.
Edit this example live in Storybook
Keep the value/onChange contract
The handleChange in the source has MUI’s exact signature — (event, newValue). Migrating means swapping markup, not rewiring state: your useState, handlers and tab panels stay untouched. Selection centers itself with api.scrollToItem(el, ’smooth’, ’center’), wired exactly as in center-on-click.
Scroll buttons that survive mobile
MUI hides its scroll buttons below 600px unless you opt in with allowScrollButtonsMobile — and even then they are internal to Tabs. Here the arrows are your own components: useIsVisible(’first’) / useIsVisible(’last’) drive an opacity fade, they render on every viewport, and touch scrolling is native regardless of what the arrows do.
Centered and scrollable, together
In MUI the centered prop and the scrollable variant are mutually exclusive — the docs tell you to pick one. Here centering is not a layout mode but a per-click scroll, so the strip is both at once: it overflows natively and every selected tab glides to the middle.
Tabs that stop being tabs
Two tabs in the demo carry count badges; chips, avatars or mixed content work the same — the only requirement is an itemId. Style with @emotion/styled as the source does, with MUI’s own styled() so it sits in a Material app, or with Tailwind. The demo above adds drag-to-scroll; restoring the selected tab on mount is save & restore position.
Notes
- Pick your ARIA pattern: keep
role="tablist"/role="tab"/aria-selectedwhen real panels switch (as here), oraria-currentwhen the “tabs” are navigation links. - With drag enabled, suppress the click that fires on drag release — the demo checks
dragManager.draggingbefore selecting, same as the drag recipe. - RTL needs no extra work: the strip is a native scroll container, so
direction: rtlflips it, arrows included.
Full source
Complete and copy-paste ready — this is the exact file behind the live-editable Storybook version.
import 'react-horizontal-scrolling-menu/dist/styles.css';
import styled from '@emotion/styled';
import React from 'react';
import {
type publicApiType,
ScrollMenu,
VisibilityContext,
} from 'react-horizontal-scrolling-menu';
const TABS = [
{ value: 'overview', label: 'Overview' },
{ value: 'analytics', label: 'Analytics' },
{ value: 'reports', label: 'Reports', count: 12 },
{ value: 'campaigns', label: 'Campaigns' },
{ value: 'audiences', label: 'Audiences' },
{ value: 'attribution', label: 'Attribution' },
{ value: 'conversions', label: 'Conversions', count: 3 },
{ value: 'realtime', label: 'Realtime' },
{ value: 'integrations', label: 'Integrations' },
{ value: 'settings', label: 'Settings' },
];
// role="tab" needs a tablist ancestor; the scroll container is exactly
// that, reached through the containerRef prop.
const tablistRef = (el: HTMLElement | null) => {
if (el) {
el.setAttribute('role', 'tablist');
el.setAttribute('aria-label', 'Sections');
}
};
export function MuiTabs() {
const [value, setValue] = React.useState(TABS[0].value);
// Same contract as MUI <Tabs onChange>: (event, newValue).
const handleChange = (
_event: React.SyntheticEvent | null,
newValue: string,
) => setValue(newValue);
return (
<Root>
<ScrollMenu
LeftArrow={LeftArrow}
RightArrow={RightArrow}
containerRef={tablistRef}
>
{TABS.map((tab) => (
<Tab
key={tab.value}
itemId={tab.value} // NOTE: itemId is required for track items
tab={tab}
selected={value === tab.value}
onSelect={(event) => handleChange(event, tab.value)}
/>
))}
</ScrollMenu>
</Root>
);
}
export default MuiTabs;
function Tab({
itemId,
tab,
selected,
onSelect,
}: {
itemId: string;
tab: (typeof TABS)[number];
selected: boolean;
onSelect: (event: React.SyntheticEvent) => void;
}) {
const api = React.useContext<publicApiType>(VisibilityContext);
const select = (event: React.SyntheticEvent) => {
onSelect(event);
const el = api.getItemElementById(itemId);
// The behavior MUI cannot combine with `scrollable`: center the
// selected tab, revealing its neighbors on both sides.
if (el) api.scrollToItem(el, 'smooth', 'center');
};
return (
<TabButton
type="button"
role="tab"
aria-selected={selected}
selected={selected}
onClick={select}
onKeyDown={(ev: React.KeyboardEvent) => {
ev.code === 'Enter' && select(ev);
}}
>
{tab.label}
{tab.count !== undefined && <Badge>{tab.count}</Badge>}
</TabButton>
);
}
function LeftArrow() {
const visibility = React.useContext<publicApiType>(VisibilityContext);
const isFirstItemVisible = visibility.useIsVisible('first', true);
return (
<ArrowButton
type="button"
hidden={isFirstItemVisible}
aria-label="Scroll tabs left"
onClick={() => visibility.scrollPrev()}
>
‹
</ArrowButton>
);
}
function RightArrow() {
const visibility = React.useContext<publicApiType>(VisibilityContext);
const isLastItemVisible = visibility.useIsVisible('last', false);
return (
<ArrowButton
type="button"
hidden={isLastItemVisible}
aria-label="Scroll tabs right"
onClick={() => visibility.scrollNext()}
>
›
</ArrowButton>
);
}
// MUI's own tab metrics: uppercase 14px labels, 48px height, a 2px
// primary indicator. Swap the styled() calls for your theme's — nothing
// below depends on these exact values.
const PRIMARY = '#1976d2';
const Root = styled('div')({
fontFamily: 'Roboto, Helvetica, Arial, sans-serif',
borderBottom: '1px solid rgba(0, 0, 0, 0.12)',
});
const TabButton = styled('button')<{ selected?: boolean }>((props) => ({
appearance: 'none',
border: 'none',
background: 'none',
cursor: 'pointer',
minWidth: '90px',
minHeight: '48px',
padding: '12px 16px',
display: 'inline-flex',
alignItems: 'center',
gap: '8px',
textTransform: 'uppercase',
fontSize: '0.875rem',
fontWeight: 500,
letterSpacing: '0.02857em',
whiteSpace: 'nowrap',
userSelect: 'none',
color: props.selected ? PRIMARY : 'rgba(0, 0, 0, 0.6)',
boxShadow: props.selected ? `inset 0 -2px 0 0 ${PRIMARY}` : 'none',
transition: 'color 0.2s, box-shadow 0.2s',
'&:hover': {
color: props.selected ? PRIMARY : 'rgba(0, 0, 0, 0.87)',
},
}));
const Badge = styled('span')({
background: PRIMARY,
color: 'white',
borderRadius: '10px',
padding: '1px 7px',
fontSize: '0.75rem',
});
// Unlike MUI's scroll buttons, these are plain components you own — they
// render on every viewport (MUI hides its buttons below 600px) and fade
// out at the edges via useIsVisible instead of unmounting.
const ArrowButton = styled('button')<{ hidden?: boolean }>((props) => ({
appearance: 'none',
border: 'none',
background: 'none',
cursor: props.hidden ? 'default' : 'pointer',
width: '40px',
fontSize: '1.5rem',
color: 'rgba(0, 0, 0, 0.54)',
opacity: props.hidden ? 0 : 1,
pointerEvents: props.hidden ? 'none' : 'auto',
transition: 'opacity 0.2s',
}));