MUI 너머의 스크롤 가능 탭

Material UI의 스크롤 가능 탭은 Tabs 시맨틱에 용접되어 있고, 스크롤 버튼은 기본적으로 모바일에서 사라집니다. 이 레시피는 여러분의 코드가 의존하는 부분 — value/onChange 계약 — 은 그대로 두고, 그 아래 스트립만 바꿉니다: 네이티브 스크롤, 스스로 가운데 정렬되는 선택, 무엇이든 담을 수 있는 탭.

어느 쪽 가장자리 근처의 탭을 클릭 — 스스로 가운데로 정렬됩니다. 휴대폰에서처럼 행을 드래그해보세요.

value/onChange 계약 유지하기

소스의 handleChange는 MUI와 정확히 같은 시그니처를 갖습니다 — (event, newValue). 마이그레이션은 상태를 다시 배선하는 게 아니라 마크업만 교체하는 것을 뜻합니다: 여러분의 useState, 핸들러, 탭 패널은 그대로입니다. 선택은 api.scrollToItem(el, ’smooth’, ’center’)로 스스로 가운데 정렬되며, center-on-click과 정확히 같은 방식으로 배선되어 있습니다.

모바일에서도 살아남는 스크롤 버튼

MUI는 allowScrollButtonsMobile로 옵트인하지 않는 한 600px 미만에서 스크롤 버튼을 숨기며, 옵트인해도 그 버튼은 Tabs 내부의 것일 뿐입니다. 여기서 화살표는 여러분 자신의 컴포넌트입니다: useIsVisible(’first’) / useIsVisible(’last’)가 불투명도 페이드를 제어하고, 모든 뷰포트에서 렌더링되며, 화살표가 무엇을 하든 터치 스크롤은 네이티브로 남습니다.

가운데 정렬과 스크롤, 동시에

MUI에서는 centered 프로퍼티와 scrollable 변형이 상호 배타적입니다 — 문서는 둘 중 하나를 고르라고 말합니다. 여기서 가운데 정렬은 레이아웃 모드가 아니라 클릭마다 일어나는 스크롤이므로, 스트립은 둘 다를 동시에 만족합니다: 네이티브로 오버플로하면서 선택된 탭마다 가운데로 미끄러져 갑니다.

더 이상 탭이 아닌 탭

데모의 탭 두 개는 카운트 배지를 달고 있습니다. 칩, 아바타, 혼합 콘텐츠도 똑같이 동작합니다 — 유일한 요구사항은 itemId뿐입니다. 소스처럼 @emotion/styled로 스타일링하거나, Material 앱에 자연스럽게 어울리도록 MUI 자체의 styled()로, 또는 Tailwind로 스타일링하세요. 위 데모는 드래그 스크롤을 더했고, 마운트 시 선택한 탭을 복원하는 것은 위치 저장과 복원입니다.

참고

전체 소스

완전하고 복사-붙여넣기 가능 — 이것이 바로 그 파일입니다. 출처는, 이 라이브 편집 가능한 Storybook 버전.

MuiTabs.source.tsx
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',
}));

관련 예제

예제 전체 22개