Tabs | CoUI
LogoCoUI

Tabs

탭 컴포넌트

Tabs#

콘텐츠를 탭으로 구분하여 표시하는 컴포넌트입니다.

Live Preview#

사용 시기 (When to Use)#

이 컴포넌트를 사용하세요:

  • 관련된 콘텐츠를 여러 패널로 나누어 전환할 때
  • 같은 맥락의 다른 뷰를 제공할 때 (개요/상세/리뷰 등)
  • 페이지 이동 없이 콘텐츠를 전환할 때

대신 다른 컴포넌트를 사용하세요:

  • Accordion: 모든 섹션을 한 페이지에서 펼치고 접을 때
  • Navigation: 앱의 주요 페이지 간 이동일 때
  • Select: 옵션 선택이 목적이고 관련 콘텐츠 패널이 없을 때

기본 사용법 (Basic Usage)#

Tabs 는 데이터 기반 탭 바입니다. tabsCoreTabItem 목록을, selectedIndex 에 현재 선택 인덱스를 전달하고 onChanged 로 전환을 감지합니다. Flutter / Web 동일한 API 입니다.

Tabs(
  tabs: const [
    CoreTabItem(label: 'Account'),
    CoreTabItem(label: 'Password'),
  ],
  selectedIndex: index,
  onChanged: (i) => setState(() => index = i),
)

문자열 배열로 간편하게 만들 수도 있습니다.

Tabs.labels(
  labels: const ['Account', 'Password'],
  selectedIndex: index,
  onChanged: (i) => setState(() => index = i),
)

Props / Parameters#

Tabs#

속성타입기본값설명
tabsList<CoreTabItem>필수탭 목록
selectedIndexint필수현재 선택된 탭 인덱스
onChanged CoreValueChanged<int>? null 탭 변경 콜백
variant CoreTabVariant pill 탭 스타일 변형 (pill / underline)
size CoreComponentSize md 탭 항목 하나의 크기 — CoreTabsStyle.defaultsBySizeitemHeight 항목을 고르는 시맨틱 식별자. 위젯 파라미터로만 정하며 테마로 오버라이드되지 않음 (높이 값 자체는 tabsStyle.itemHeight 로 오버라이드)
expand bool false 탭이 전체 너비를 균등 분할
swipeable bool true 좌우 스와이프로 탭 전환
tabsStyle CoreTabsStyle? null 인스턴스 스타일 (Style 시스템 참조)

CoreTabItem#

tabs 목록의 항목 하나. Flutter / Web 동일한 데이터 타입입니다.

속성타입기본값설명
labelString필수탭에 표시되는 텍스트
enabledbooltrue선택 가능 여부
badge W? (Widget? / Component?) null 라벨 뒤에 그려지는 배지 — 보통 Badge(form: .count, child: Text('3')) . 호출자가 완성된 위젯을 넘기는 합성 슬롯이고, 라벨과의 간격만 CoreTabsStyle.labelBadgeGapStyle 이 정합니다. Tabs / TabPane 둘 다 렌더합니다
content W? (Widget? / Component?) null 콘텐츠 패널 — TabPane 만 소비하고 Tabs 는 무시합니다
leading W? (Widget? / Component?) null 라벨 에 그려지는 위젯 — 에디터 탭의 파일 종류 아이콘 같은 것. TabPane 만 소비하고 Tabs 는 무시합니다. 간격은 CoreTabPaneStyle.labelBadgeGapStyle (탭 내부 공통 한 걸음)
trailing W? (Widget? / Component?) null 라벨과 badge 에 그려지는 위젯 — 에디터 탭의 닫기 어피던스 같은 것. TabPane 만 소비하고 Tabs 는 무시합니다. 간격은 leading 과 같은 슬롯

박스형 탭 + 콘텐츠 패널을 함께 그리는 TabPane 은 별도 컴포넌트입니다 (자체 content 슬롯 · reorderable · CoreTabPaneStyle).

스타일 시스템 (Style System)#

Tabs 의 모든 chrome / dimensional 오버라이드는 CoreTabsStyle 단일 슬롯으로 흐릅니다. 시맨틱 enum (variant) 과 behaviour (tabs / selectedIndex / onChanged / expand / swipeable) 는 위젯/컴포넌트 파라미터로 직접 전달합니다.

Resolve chain#

CoreTabsStyle.defaultX / defaultsByVariant   // 디자인 시스템 기본값
  → CoreTabsTheme.style                      // 프로젝트 공통
  → CoreTabsTheme.variantStyles[variant]     // 프로젝트 variant 별
  → widget.tabsStyle                         // 인스턴스별

CoreTabsStyle 필드#

필드타입설명
backgroundColor CoreColor? Tab-bar row background colour. null → variant default.
activeIndicatorColor CoreColor? Active-indicator (pill fill / underline) colour. null → variant default.
activeIndicatorRadius CoreBorderRadius? Active-indicator border radius. null → token default.
activeIndicatorThickness double? Active-indicator (underline) thickness (logical px). null → token default.
borderColor CoreColor? Tab/content separator border colour (underline variant). null → variant default.
borderWidth double? Tab/content separator border thickness (logical px; underline variant). null → token default.
activeContentColor CoreColor? Active tab content colour. null → token default.
inactiveContentColor CoreColor? Inactive tab content colour. null → variant default.
tabBarPadding CoreEdgeInsets? Tab-bar container padding. null → variant default.
tabPadding CoreEdgeInsets? Per-tab button padding. null → variant default.
containerRadius CoreBorderRadius? Tab-bar container corner radius. null → token default. Applies only to the pill variant.
indicatorTransitionDuration Duration? Active-indicator colour-transition duration. null → token default ([defaultIndicatorTransitionDuration]).
swipeSensitivity double? Minimum horizontal drag distance / swipe velocity to trigger a tab switch (logical px / sec). A velocity value — not a layout dimension — so resolvers forward this unscaled. null → token default ([defaultSwipeSensitivity]).
tabSpacing double? Inter-tab spacing (logical px) between adjacent tab buttons. Flutter applies this to Row(spacing:) ; Web emits it as the flex gap inline CSS. null → token default ([defaultTabSpacing]).
itemHeight double? Individual tab item height (logical px; Epic K axis 8-2). null → the widget's size -keyed entry in [defaultsBySize]. Distinct from CoreTabPaneStyle.defaultBarHeight (the surrounding TabPane bar container) — see [defaultsBySize].
contentTextStyle CoreTextStyle? Content / tab-label text style override.
clickableStyle CoreClickableStyle? Nested [CoreClickableStyle] slot for the composed per-tab Clickable (press scale / durations / focus ring / disabled opacity). Merged on top of [defaultClickableStyle] and raw-forwarded — the Clickable's own resolver fills the rest.
labelBadgeGapStyle CoreGapStyle? Nested [CoreGapStyle] slot for the 1-off spacer between a tab's label and its CoreTabItem.badge . null → token default ([defaultLabelBadgeGapStyle]). Merged on top of that default and raw-forwarded to the Gap widget the tab composes.

CoreTabsStyle 변형별 기본값 (CoreTabVariantStyle)#

필드pillunderline
activeIndicatorColorsurfaceprimary
inactiveContentColor onSurface (opacity defaultInactiveContentOpacity) onSurfaceVariant
tabBarPadding CoreEdgeInsets.symmetric(horizontal: CoreSpace.space4, vertic… CoreEdgeInsets.symmetric(horizontal: CoreSpace.space16, verti…
tabPadding CoreEdgeInsets.symmetric(horizontal: CoreSpace.space12, verti… CoreEdgeInsets.symmetric(horizontal: CoreSpace.space16, verti…
backgroundColorsurfaceContainer
borderColoroutline

빠른 오버라이드 (Chain)#

이미 만든 Tabs 인스턴스에 스타일을 빠르게 덧붙이고 싶다면 withStyle 체인을 쓸 수 있습니다. 생성자의 style 슬롯 인자와 동일하게 동작하지만, 이미 구성된 위젯 위에서 바로 이어 쓸 수 있습니다. .surfaceContainer처럼 Core 토큰 상수 이름과 똑같은 이름의 getter도 있습니다 — withStyle을 한 번 더 줄인 sugar로, 이름이 곧 값이라(surfaceContainer == CoreColors.surfaceContainer) 어느 컴포넌트에서 써도 뜻이 갈리지 않으며, 위 예시처럼 withStyle 뒤에 이어붙일 수도 있습니다.

class TabsChainExample extends StatefulWidget {
  const TabsChainExample({super.key});

  @override
  State<TabsChainExample> createState() => _TabsChainExampleState();
}

class _TabsChainExampleState extends State<TabsChainExample> {
  static const _contents = [
    'Manage your account settings.',
    'Change your password here.',
  ];

  int _index = 0;

  @override
  Widget build(BuildContext context) {
    return Column(
      mainAxisSize: MainAxisSize.min,
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        Tabs(
              tabs: const [
                CoreTabItem(label: 'Account'),
                CoreTabItem(label: 'Password'),
              ],
              selectedIndex: _index,
              onChanged: (i) => setState(() => _index = i),
            )
            .withStyle(
              const CoreTabsStyle(
                tabPadding: CoreEdgeInsets.symmetric(
                  horizontal: CoreSpace.space16,
                  vertical: CoreSpace.space8,
                ),
                containerRadius: CoreBorderRadius.all(CoreRadius.radius24),
                tabSpacing: CoreSpace.space8,
              ),
            )
            .surfaceContainer,
        const Gap.space16(),
        Text(_contents[_index]).bodyMedium.onSurface,
      ],
    );
  }
}
class TabsChainExample extends StatefulComponent {
  const TabsChainExample({super.key});

  @override
  State<TabsChainExample> createState() => _TabsChainExampleState();
}

class _TabsChainExampleState extends State<TabsChainExample> {
  static const _contents = [
    'Manage your account settings.',
    'Change your password here.',
  ];

  int _index = 0;

  @override
  Component build(BuildContext context) {
    return div(
      [
        Tabs(
              tabs: const [
                CoreTabItem(label: 'Account'),
                CoreTabItem(label: 'Password'),
              ],
              selectedIndex: _index,
              onChanged: (index) => setState(() => _index = index),
            )
            .withStyle(
              const CoreTabsStyle(
                tabPadding: CoreEdgeInsets.symmetric(
                  horizontal: CoreSpace.space16,
                  vertical: CoreSpace.space8,
                ),
                containerRadius: CoreBorderRadius.all(CoreRadius.radius24),
                tabSpacing: CoreSpace.space8,
              ),
            )
            .surfaceContainer,
        Gap.space16(),
        Text(_contents[_index]).bodyMedium.onSurface,
      ],
      classes: 'flex flex-col items-start',
    );
  }
}

변형 (Variants)#

필 탭 (default)#

둥근 컨테이너 안에 활성 탭을 채워진 "필" 인디케이터로 강조합니다.

Web
Manage your account settings.
Flutter
Loading Flutter...
class TabsDefaultExample extends StatefulComponent {
  const TabsDefaultExample({super.key});

  @override
  State<TabsDefaultExample> createState() => _TabsDefaultExampleState();
}

class _TabsDefaultExampleState extends State<TabsDefaultExample> {
  static const _contents = [
    'Manage your account settings.',
    'Change your password here.',
  ];

  int _index = 0;

  @override
  Component build(BuildContext context) {
    return div(
      [
        Tabs(
          tabs: const [
            CoreTabItem(label: 'Account'),
            CoreTabItem(label: 'Password'),
          ],
          selectedIndex: _index,
          onChanged: (index) => setState(() => _index = index),
        ),
        Gap.space16(),
        Text(_contents[_index]).bodyMedium.onSurface,
      ],
      classes: 'flex flex-col items-start',
    );
  }
}
class TabsDefaultExample extends StatefulWidget {
  const TabsDefaultExample({super.key});

  @override
  State<TabsDefaultExample> createState() => _TabsDefaultExampleState();
}

class _TabsDefaultExampleState extends State<TabsDefaultExample> {
  static const _contents = [
    'Manage your account settings.',
    'Change your password here.',
  ];

  int _index = 0;

  @override
  Widget build(BuildContext context) {
    return Column(
      mainAxisSize: MainAxisSize.min,
      crossAxisAlignment: CrossAxisAlignment.stretch,
      children: [
        Tabs(
          tabs: const [
            CoreTabItem(label: 'Account'),
            CoreTabItem(label: 'Password'),
          ],
          selectedIndex: _index,
          onChanged: (i) => setState(() => _index = i),
        ),
        const Gap.space16(),
        Text(_contents[_index]).bodyMedium.onSurface,
      ],
    );
  }
}

라인 탭#

콘텐츠와 하단 보더로 구분되고, 활성 탭 아래에 밑줄 인디케이터가 표시됩니다. 하단 보더는 탭 바 박스의 것이라 expand: false 여도 부모가 준 너비 전체에 그려지고, 탭 행만 그 안에서 시작 방향에 붙어 hug 합니다 (Flutter / Web 동일).

Web
Manage your account settings.
Flutter
Loading Flutter...
class TabsLineExample extends StatefulComponent {
  const TabsLineExample({super.key});

  @override
  State<TabsLineExample> createState() => _TabsLineExampleState();
}

class _TabsLineExampleState extends State<TabsLineExample> {
  static const _contents = [
    'Manage your account settings.',
    'Change your password here.',
  ];

  int _index = 0;

  @override
  Component build(BuildContext context) {
    return div(
      [
        Tabs(
          tabs: const [
            CoreTabItem(label: 'Account'),
            CoreTabItem(label: 'Password'),
          ],
          selectedIndex: _index,
          variant: CoreTabVariant.underline,
          onChanged: (index) => setState(() => _index = index),
        ),
        Gap.space16(),
        Text(_contents[_index]).bodyMedium.onSurface,
      ],
      classes: 'flex flex-col items-stretch w-full',
    );
  }
}
class TabsLineExample extends StatefulWidget {
  const TabsLineExample({super.key});

  @override
  State<TabsLineExample> createState() => _TabsLineExampleState();
}

class _TabsLineExampleState extends State<TabsLineExample> {
  static const _contents = [
    'Manage your account settings.',
    'Change your password here.',
  ];

  int _index = 0;

  @override
  Widget build(BuildContext context) {
    return Column(
      mainAxisSize: MainAxisSize.min,
      crossAxisAlignment: CrossAxisAlignment.stretch,
      children: [
        Tabs(
          tabs: const [
            CoreTabItem(label: 'Account'),
            CoreTabItem(label: 'Password'),
          ],
          selectedIndex: _index,
          variant: CoreTabVariant.underline,
          onChanged: (i) => setState(() => _index = i),
        ),
        const Gap.space16(),
        Text(_contents[_index]).bodyMedium.onSurface,
      ],
    );
  }
}
Tabs(
  tabs: const [
    CoreTabItem(label: 'Account'),
    CoreTabItem(label: 'Password'),
  ],
  selectedIndex: index,
  variant: CoreTabVariant.underline,
  onChanged: (i) => setState(() => index = i),
)

박스형 탭 + 통합 콘텐츠 패널이 필요하면 별도 컴포넌트 TabPane 을 사용하세요.

배지 탭#

CoreTabItem.badgeBadge 를 넘기면 라벨 뒤에 그려집니다. 배지는 호출자가 완성해서 넘기는 합성 슬롯이라 자기 색을 직접 칠하고(라벨 색을 상속하지 않음), 라벨과의 간격만 CoreTabsStyle.labelBadgeGapStyle (기본 CoreSpace.space4) 이 정합니다. 배지가 없는 탭은 간격도 그리지 않습니다. 두 변형과 TabPane 모두 같은 모양으로 렌더합니다 (Flutter / Web 동일).

Web
You have 3 unread messages.
Flutter
Loading Flutter...
class TabsBadgeExample extends StatefulComponent {
  const TabsBadgeExample({super.key});

  @override
  State<TabsBadgeExample> createState() => _TabsBadgeExampleState();
}

class _TabsBadgeExampleState extends State<TabsBadgeExample> {
  static const _contents = [
    'You have 3 unread messages.',
    'Nothing sent yet.',
    'Drafts are saved automatically.',
  ];

  int _index = 0;

  @override
  Component build(BuildContext context) {
    return div(
      [
        Tabs(
          tabs: [
            CoreTabItem(
              label: 'Inbox',
              badge: Badge(form: CoreBadgeForm.count, child: Text('3')),
            ),
            const CoreTabItem(label: 'Sent'),
            const CoreTabItem(label: 'Drafts'),
          ],
          selectedIndex: _index,
          onChanged: (index) => setState(() => _index = index),
        ),
        Gap.space16(),
        Text(_contents[_index]).bodyMedium.onSurface,
      ],
      classes: 'flex flex-col items-start',
    );
  }
}
class TabsBadgeExample extends StatefulWidget {
  const TabsBadgeExample({super.key});

  @override
  State<TabsBadgeExample> createState() => _TabsBadgeExampleState();
}

class _TabsBadgeExampleState extends State<TabsBadgeExample> {
  static const _contents = [
    'You have 3 unread messages.',
    'Nothing sent yet.',
    'Drafts are saved automatically.',
  ];

  int _index = 0;

  @override
  Widget build(BuildContext context) {
    return Column(
      mainAxisSize: MainAxisSize.min,
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        Tabs(
          tabs: const [
            CoreTabItem(
              label: 'Inbox',
              badge: Badge(form: CoreBadgeForm.count, child: Text('3')),
            ),
            CoreTabItem(label: 'Sent'),
            CoreTabItem(label: 'Drafts'),
          ],
          selectedIndex: _index,
          onChanged: (i) => setState(() => _index = i),
        ),
        const Gap.space16(),
        Text(_contents[_index]).bodyMedium.onSurface,
      ],
    );
  }
}
Tabs(
  tabs: [
    CoreTabItem(
      label: 'Inbox',
      badge: Badge(form: CoreBadgeForm.count, child: Text('3')),
    ),
    const CoreTabItem(label: 'Sent'),
  ],
  selectedIndex: index,
  onChanged: (i) => setState(() => index = i),
)

비활성화 탭#

CoreTabItem(label: '준비 중', enabled: false)

동작 스펙 (Behavior)#

탭 전환#

  • 탭 클릭 시 해당 콘텐츠 패널이 즉시 표시
  • onChanged 콜백으로 탭 전환 이벤트 감지
  • 활성 인디케이터는 CoreDuration.ms100 으로 부드럽게 전환

스와이프#

  • swipeable: true (기본값) 일 때 탭 바에서 좌우 스와이프로 다음/이전 탭 이동
  • 임계값은 CoreTabTokens.swipeSensitivity (20 logical px)

테마 설정#

CoreComponentTheme(
  tabs: CoreTabsTheme(
    style: CoreTabsStyle(
      tabSpacing: CoreSpace.space4,
    ),
  ),
)

사용 가이드라인 (Usage Guidelines)#

✅ Do#

탭 레이블은 짧고 명확하게 작성하세요.

Tabs(
  tabs: const [
    CoreTabItem(label: '개요'),
    CoreTabItem(label: '리뷰'),
    CoreTabItem(label: '사양'),
  ],
  selectedIndex: index,
  onChanged: handleTabChange,
)

한두 단어로 각 탭의 내용을 명확히 전달합니다.


❌ Don't#

탭 레이블이 너무 길거나 모호하지 않게 하세요.

Tabs(
  tabs: const [
    CoreTabItem(label: '제품의 전반적인 개요 및 소개'),
    CoreTabItem(label: '사용자 리뷰 및 평가'),
  ],
  selectedIndex: index,
  onChanged: handleTabChange,
)

긴 레이블은 탭 바 공간을 차지하고 스캔하기 어렵습니다.

✅ Do#

관련 콘텐츠끼리 탭으로 묶으세요.

Tabs(
  tabs: const [
    CoreTabItem(label: '기본 정보'),
    CoreTabItem(label: '보안 설정'),
    CoreTabItem(label: '알림 설정'),
  ],
  selectedIndex: index,
  onChanged: handleTabChange,
)

같은 맥락(설정)의 하위 카테고리를 탭으로 나누면 자연스럽습니다.


❌ Don't#

관계없는 콘텐츠를 탭으로 묶지 마세요.

독립된 기능은 별도 페이지(Navigation)로 분리하세요.

접근성 (Accessibility)#

키보드 인터랙션#

동작
Tab탭 목록으로 포커스 이동
Enter / Space포커스된 탭 선택

스크린 리더#

  • Flutter: Semantics 로 탭 역할과 현재 선택 상태가 전달
  • Web: role="tab", role="tablist", role="tabpanel", aria-selected 적용

ARIA 속성#

<div role="tablist">
  <button role="tab" aria-selected="true">탭 1</button>
  <button role="tab" aria-selected="false">탭 2</button>
</div>

크로스 플랫폼 차이점 (Platform Differences)#

Tabs / TabPane 은 Flutter / Web 에서 동일한 named properties API 를 사용합니다. 파라미터 이름·구조·동작이 양쪽 동일합니다.

항목FlutterWeb
드래그 정렬Draggable / DragTargetHTML5 drag-and-drop
가로 스크롤 FadeScroll + SingleChildScrollView overflow-x-auto
스와이프 GestureDetector.onHorizontalDragEnd touchstart / touchend
  • Accordion: 접고 펼 수 있는 콘텐츠 섹션. 모든 섹션을 동시에 볼 수 있어야 할 때 적합
  • Navigation: 앱의 주요 섹션 간 이동. 탭과 달리 URL이 변경됨