超越 MUI 的可滚动标签页
Material UI 的可滚动标签页与 Tabs 的语义焊死在一起,滚动按钮还会在移动端默认消失。此配方保留了你的代码所依赖的部分——
value/onChange 契约——并换掉底下的这一行:原生滚动、能自行居中的选中项,以及可以容纳任何内容的标签。点击靠近任一边缘的标签——它会自行居中。拖拽这一行,就像在手机上一样。
保留 value/onChange 契约
源码中的 handleChange 用的是与 MUI 完全相同的签名——(event, newValue)。迁移换的是标签标记,而不是重接状态:你的 useState、事件处理器与标签面板都原封不动。选中项通过 api.scrollToItem(el, ’smooth’, ’center’) 自行居中,接线方式与 居中选中 完全一致。
在移动端也不会消失的滚动按钮
MUI 在 600px 以下会隐藏滚动按钮,除非你用 allowScrollButtonsMobile 主动开启——即便开启了,它们也只是 Tabs 内部的实现。这里的箭头是你自己的组件:useIsVisible(’first’) / useIsVisible(’last’) 驱动一次透明度渐隐,它们在任何视口下都会渲染,触摸滚动则始终原生,与箭头做什么无关。
居中与可滚动,二者兼得
在 MUI 中,centered 属性与 scrollable 变体互斥——文档告诉你只能二选一。这里的居中不是一种布局模式,而是每次点击触发的一次滚动,因此这一行二者兼得:它原生地溢出滚动,且每个被选中的标签都会滑向中间。
不止是标签的标签
演示中的两个标签带有计数徽章;换成 Chip、Avatar 或混合内容同样可行——唯一的要求是一个 itemId。你可以像源码那样用 @emotion/styled 设置样式,也可以用 MUI 自己的 styled() 让它融入 Material 应用,或者直接用 Tailwind。上面的演示还加上了 拖拽滚动;在挂载时恢复选中的标签,那正是 保存与恢复位置 的做法。
注意
完整源码
完整且可直接复制粘贴——这正是背后这份文件的 可在 Storybook 中实时编辑的版本.
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',
}));