Onglets défilables au-delà de MUI
value/onChange — et remplace la bande sous-jacente : défilement natif, une sélection qui se centre elle-même, des onglets capables de contenir n’importe quoi.Cliquez sur un onglet près de l’un ou l’autre bord — il se centre lui-même. Faites glisser la rangée, comme sur un téléphone.
Modifier cet exemple en direct dans Storybook
Garder le contrat value/onChange
Le handleChange de la source a exactement la signature de MUI — (event, newValue). Migrer signifie remplacer le balisage, pas recâbler l’état : vos useState, gestionnaires et panneaux d’onglets restent intacts. La sélection se centre elle-même avec api.scrollToItem(el, ’smooth’, ’center’), câblée exactement comme dans l’exemple de centrage au clic.
Boutons de défilement qui survivent sur mobile
MUI masque ses boutons de défilement en dessous de 600px, sauf si vous activez allowScrollButtonsMobile — et même alors, ils restent internes à Tabs. Ici, les flèches sont vos propres composants : useIsVisible(’first’) / useIsVisible(’last’) pilotent un fondu d’opacité, elles s’affichent sur chaque viewport, et le défilement tactile reste natif, quoi que fassent les flèches.
Centrer et défiler, à la fois
Dans MUI, la prop centered et le variant scrollable s’excluent mutuellement — la documentation vous dit d’en choisir un. Ici, le centrage n’est pas un mode de mise en page mais un défilement au clic, donc la bande est les deux à la fois : elle déborde nativement et chaque onglet sélectionné glisse jusqu’au milieu.
Onglets qui cessent d’être des onglets
Deux onglets de la démo portent des badges de comptage ; puces, avatars ou contenu mixte fonctionnent de la même façon — la seule exigence est un itemId. Stylez avec @emotion/styled comme le fait la source, avec le styled() propre à MUI pour qu’il s’intègre dans une application Material, ou avec Tailwind. La démo ci-dessus ajoute le glisser pour défiler ; restaurer l’onglet sélectionné au montage, c’est sauvegarder et restaurer la position.
Notes
- Choisissez votre motif ARIA : gardez
role="tablist"/role="tab"/aria-selectedquand de vrais panneaux basculent (comme ici), ouaria-currentquand les « onglets » sont des liens de navigation. - Avec le glisser activé, supprimez le clic qui se déclenche au relâchement du glissement — la démo vérifie
dragManager.draggingavant de sélectionner, comme la recette de glisser pour défiler. - Le RTL ne demande aucun travail supplémentaire : la bande est un conteneur de défilement natif, donc
direction: rtll’inverse, flèches comprises.
Source complète
Complète et prête à copier-coller — c’est le fichier exact derrière la version Storybook modifiable en direct.
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',
}));