Pestañas desplazables más allá de MUI
value/onChange — y cambia la franja de debajo: desplazamiento nativo, una selección que se centra sola, pestañas que pueden contener cualquier cosa.Haz clic en una pestaña cerca de cualquiera de los bordes — se centra sola. Arrastra la fila, como en un móvil.
Edita este ejemplo en vivo en Storybook
Mantén el contrato value/onChange
El handleChange de la fuente tiene la firma exacta de MUI — (event, newValue). Migrar significa cambiar el marcado, no recablear el estado: tu useState, los manejadores y los paneles de pestaña quedan intactos. La selección se centra sola con api.scrollToItem(el, ’smooth’, ’center’), conectado exactamente igual que en centrar al hacer clic.
Botones de desplazamiento que sobreviven en móvil
MUI oculta sus botones de desplazamiento por debajo de 600px salvo que lo actives con allowScrollButtonsMobile — e incluso entonces son internos a Tabs. Aquí las flechas son tus propios componentes: useIsVisible(’first’) / useIsVisible(’last’) impulsan un desvanecido de opacidad, se renderizan en cualquier viewport, y el desplazamiento táctil es nativo sin importar lo que hagan las flechas.
Centrado y desplazable, a la vez
En MUI la prop centered y la variant scrollable son mutuamente excluyentes — la documentación te dice que elijas una. Aquí centrar no es un modo de diseño sino un desplazamiento por clic, así que la franja es ambas cosas a la vez: desborda de forma nativa y cada pestaña seleccionada se desliza hasta el centro.
Pestañas que dejan de ser pestañas
Dos pestañas de la demo llevan insignias de contador; los chips, avatares o contenido mixto funcionan igual — el único requisito es un itemId. Dales estilo con @emotion/styled como hace la fuente, con el styled() propio de MUI para que encaje en una aplicación Material, o con Tailwind. La demo de arriba añade arrastrar para desplazarse; restaurar la pestaña seleccionada al montar es guardar y restaurar posición.
Notas
- Elige tu patrón ARIA: conserva
role="tablist"/role="tab"/aria-selectedcuando cambian paneles reales (como aquí), oaria-currentcuando las "pestañas" son enlaces de navegación. - Con el arrastre activado, suprime el clic que se dispara al soltar el arrastre — la demo comprueba
dragManager.draggingantes de seleccionar, igual que la receta de arrastre. - RTL no necesita trabajo extra: la franja es un contenedor de desplazamiento nativo, así que
direction: rtlla invierte, flechas incluidas.
Fuente completa
Completa y lista para copiar y pegar: este es el archivo exacto que está detrás de la versión de Storybook editable en vivo.
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',
}));
Ejemplos relacionados
- Centrar el elemento clicadoscrollToItem con inline: center — el patrón de pestañas desplazables.
- Arrastrar para desplazarse con el ratónArrastre con el ratón que sigue dejando funcionar los clics.
- Guardar y restaurar la posición de desplazamientoMantén el desplazamiento entre desmontajes y recargas de página.