Tabs | CoUI
본문으로 건너뛰기
LogoCoUI

Tabs

탭 컴포넌트

목차

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

기본 사용법 (Basic Usage)#

Tabs 는 데이터 기반 탭 바입니다. tabs 에 CoreTabItem 목록을, 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),
)

Live Preview#

구조 (Anatomy)#

코드가 정본입니다. Tabs 가 그리는 부분은 CoreTabItem.label / CoreTabItem.badge / CoreTabItem.onClose 와 Figma ❖ Tabs 레이어입니다. content · leading · trailing 은 TabPane 만 소비하므로 여기 표에 넣지 않았습니다.

부분역할슬롯
row탭 버튼 가로 줄tabs
label탭 이름CoreTabItem.label
badge라벨 뒤 배지CoreTabItem.badge
close 라벨·배지 뒤 닫기 × — Figma TabItem 의 Show close CoreTabItem.onClose

사용 시기 (When to Use)#

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

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

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

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

Props / Parameters#

W는 CoreTabItem의 합성 슬롯 타입으로 Flutter의 Widget, Web의 Component입니다. selectedIndex와 onChanged는 0-based 인덱스를 사용합니다.

Tabs#

속성타입기본값설명
tabs List<CoreTabItem<W>> 필수 탭 목록
selectedIndexint필수현재 선택된 탭 인덱스
onChanged CoreValueChanged<int>? null 탭 변경 콜백
variant CoreTabVariant pill 탭 스타일 변형 (pill / underline)
size CoreTabsSize md 탭 바의 크기 단계 ( sm 32 · md 40 · lg 48) — pill 은 트랙이 이 높이에 서고 탭은 트랙 안쪽 여백을 뺀 28 · 36 · 44, underline 은 탭이 이 높이입니다. CoreTabsStyle.defaultsBySize 의 itemHeight + 라벨 역할( sm labelMedium 13 · md labelLarge 14 · lg labelXl 15, 두 변형 같음)을 고르는 시맨틱 식별자. 굵기는 변형 행이 선택 탭 600 · 나머지 500 으로 정합니다. 위젯 파라미터로만 정하며 테마로 오버라이드되지 않음 (단계 값 자체는 tabsStyle.itemHeight 로 오버라이드). 단계는 이 컴포넌트 전용 3단( sm · md · lg )뿐입니다
expand bool false 탭이 전체 너비를 균등 분할
scrollable bool false 라벨을 생략하지 않고 가로 스크롤 (expand보다 우선, 드래그 선택 전환 중지)
swipeable bool true 좌우 스와이프로 탭 전환
tabsStyle CoreTabsStyle? null 인스턴스 스타일 (Style 시스템 참조)

CoreTabItem#

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

속성타입기본값설명
labelString필수탭에 표시되는 텍스트
enabledbooltrue선택 가능 여부
badge W? null 라벨 뒤에 그려지는 배지 — 보통 숫자 배지 Badge(variant: .filled, form: .count, size: .xs, child: Text('3')) . 호출자가 완성된 위젯을 넘기는 합성 슬롯이고, 컨테이너는 라벨과의 간격만 정합니다 — Tabs 는 CoreTabsStyle.labelBadgeGapStyle (6), TabPane 은 CoreTabPaneStyle.labelBadgeGapStyle (4). Tabs / TabPane 둘 다 렌더합니다
content W? null 콘텐츠 패널 — TabPane 만 소비하고 Tabs 는 무시합니다
leading W? null 라벨 앞 에 그려지는 위젯 — 에디터 탭의 파일 종류 아이콘 같은 것. TabPane 만 소비하고 Tabs 는 무시합니다. 간격은 CoreTabPaneStyle.labelBadgeGapStyle (탭 내부 공통 한 걸음)
trailing W? null 라벨과 badge 뒤 에 그려지는 위젯. TabPane 만 소비. 닫기는 onClose 를 권장 — trailing 만으로 커스텀 닫기를 넣어도 select 와 격리됩니다
onClose CoreVoidCallback? null 탭 닫기 콜백. Tabs · TabPane 둘 다 렌더합니다 — 라벨과 badge 뒤에 닫기 × 를 그리고, × 클릭은 onChanged 를 호출하지 않습니다. × 의 이름(스크린 리더)은 렌더 시점 로케일의 닫기 문구입니다

박스형 탭 + 콘텐츠 패널을 함께 그리는 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. Only the pill track applies it; the underline bar has no container inset.
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 → the variant row's [CoreTabVariantStyle.tabSpacing], then [defaultTabSpacing].
itemHeight double? The bar's size rung (logical px). null → the widget's size -keyed entry in [defaultsBySize]. A pill bar draws its track at this height and each tab at it less [tabBarPadding]'s top and bottom; an underline bar draws each tab at it — see [defaultsBySize]. Distinct from CoreTabPaneStyle.defaultBarHeight (the surrounding TabPane bar container).
disabledContentColor CoreColor? Label / indicator colour when a tab item is disabled ( CoreTabItem.enabled == false ). null → token default ([defaultDisabledContentColor]).
hoverStateLayerColor CoreColor? Hover state-layer colour, laid over the tab item while the pointer is over it (and the item is enabled). null → token default ([defaultHoverStateLayerColor]).
pressedStateLayerColor CoreColor? Pressed state-layer colour. null → token default ([defaultPressedStateLayerColor]).
contentTextStyle CoreTextStyle? Content / tab-label text style override. null → the variant row's [CoreTabVariantStyle.contentTextStyle] where it states one, else the widget's size -keyed entry in [defaultsBySize], which varies by rung (one label role per rung, md being [defaultContentTextStyle]) — not a single flat default. A fontWeight stated here wins over the variant row's per-selection weights.
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. The same step separates the label (and badge) from the close affordance.
closeButtonStyle CoreButtonStyle? Nested [CoreButtonStyle] slot for the close affordance a tab given CoreTabItem.onClose draws after its label and badge. Merged on top of [defaultCloseButtonStyle] and raw-forwarded to the composed Button — its own resolver fills the rest.
closeIconStyle CoreIconStyle? Nested [CoreIconStyle] slot for the close glyph. Merged on top of [defaultCloseIconStyle] and raw-forwarded to the composed Icon .

CoreTabsStyle 변형별 기본값 (CoreTabVariantStyle)#

필드pillunderline
activeIndicatorColorsurfaceRaisedprimary
disabledIndicatorColordisabledContainerdisabledContent
inactiveContentColoronSurfaceSubtleonSurfaceSubtle
tabBarPaddingCoreEdgeInsets.all(CoreSpace.space2)zero
tabPadding CoreEdgeInsets.symmetric(horizontal: CoreSpace.space12) zero
backgroundColorsurfaceContainer—
activeFontWeightsemiBoldsemiBold
inactiveFontWeightmediummedium
tabSpacingspace2space20
sizeRungIsTracktruefalse
borderColor—outlineVariant

빠른 오버라이드 (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)#

둥근 트랙 안에 활성 탭을 채워진 "필" 인디케이터로 강조합니다. 트랙은 surfaceContainer, 선택된 칩은 그보다 한 단 위의 면인 surfaceRaised 로 칠합니다 — Light 에서는 흰 칩, Dark 에서는 트랙보다 밝은 칩입니다. 선택 탭의 글자는 onSurface · 600, 나머지는 onSurfaceSubtle · 500 입니다. 트랙 위의 onSurfaceSubtle 은 CoUI Light 에서 4.26 : 1 로 글자 기준 4.5 에 못 미치는데, 선택은 칩이 말하므로 디자이너가 받아들인 예외입니다.

크기 단계(sm 32 · md 40 · lg 48)는 트랙 의 높이입니다. 탭은 트랙 안쪽 여백(tabBarPadding, 위아래 각 2)을 뺀 28 · 36 · 44 에 섭니다. 그래서 필 탭은 같은 크기의 버튼 · 입력과 한 줄에 놓였을 때 바깥 높이가 맞습니다. tabsStyle.itemHeight 로 단계를 덮어쓰면 트랙이 그 높이에 서고, tabBarPadding 을 바꾸면 탭이 그만큼 줄거나 늘어 트랙 높이는 그대로입니다 (Flutter / Web 동일). 단계가 트랙의 높이인지 탭 하나의 높이인지는 변형 행의 CoreTabVariantStyle.sizeRungIsTrack 이 정하고(pill 은 true, underline 은 false), 두 플랫폼이 변형 이름이 아니라 이 값을 읽습니다.

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.badge 에 Badge 를 넘기면 라벨 뒤에 그려집니다. 배지는 호출자가 완성해서 넘기는 합성 슬롯이라 자기 색을 직접 칠하고(라벨 색을 상속하지 않음), 라벨과의 간격만 CoreTabsStyle.labelBadgeGapStyle (기본 CoreSpace.space6) 이 정합니다. 배지가 없는 탭은 간격도 그리지 않습니다. Tabs 의 두 변형은 같은 모양으로 렌더하고, TabPane 은 자기 배지 간격(CoreTabPaneStyle.labelBadgeGapStyle, 기본 4)을 따로 갖습니다 (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(
                variant: CoreBadgeVariant.filled,
                form: CoreBadgeForm.count,
                size: CoreComponentSize.xs,
                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(
                variant: CoreBadgeVariant.filled,
                form: CoreBadgeForm.count,
                size: CoreComponentSize.xs,
                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(
        variant: CoreBadgeVariant.filled,
        form: CoreBadgeForm.count,
        size: CoreComponentSize.xs,
        child: Text('3'),
      ),
    ),
    const CoreTabItem(label: 'Sent'),
  ],
  selectedIndex: index,
  onChanged: (i) => setState(() => index = i),
)

닫을 수 있는 탭#

CoreTabItem.onClose 를 넘기면 라벨과 배지 뒤에 닫기 × 가 그려집니다 — 열린 파일처럼 사용자가 목록에서 빼는 탭입니다. × 는 탭의 선택 영역 밖에 있어 눌러도 onChanged 가 호출되지 않고, 목록에서 그 항목을 빼는 것은 호출자의 일입니다. TabPane 과 같은 규칙입니다 (Flutter / Web 동일).

Web
Editing main.dart.
Flutter
Loading Flutter...
코드 보기
class TabsClosableExample extends StatefulComponent {
  const TabsClosableExample({super.key});

  @override
  State<TabsClosableExample> createState() => _TabsClosableExampleState();
}

class _TabsClosableExampleState extends State<TabsClosableExample> {
  static const _files = ['main.dart', 'app.dart', 'theme.dart'];

  final _open = [..._files];
  int _index = 0;

  void _close(int index) {
    setState(() {
      _open.removeAt(index);
      if (index < _index || _index == _open.length) {
        _index = (_index - 1).clamp(0, _open.length);
      }
    });
  }

  void _reopen() {
    setState(() {
      _open
        ..clear()
        ..addAll(_files);
      _index = 0;
    });
  }

  @override
  Component build(BuildContext context) {
    return div(
      [
        if (_open.isEmpty)
          Button(
            variant: CoreButtonVariant.outline,
            onPressed: _reopen,
            child: const Text('Reopen files'),
          )
        else
          Tabs(
            tabs: [
              for (final (i, file) in _open.indexed) CoreTabItem(label: file, onClose: () => _close(i)),
            ],
            selectedIndex: _index,
            onChanged: (index) => setState(() => _index = index),
          ),
        Gap.space16(),
        Text(
          _open.isEmpty ? 'All files closed.' : 'Editing ${_open[_index]}.',
        ).bodyMedium.onSurface,
      ],
      classes: 'flex flex-col items-start',
    );
  }
}
class TabsClosableExample extends StatefulWidget {
  const TabsClosableExample({super.key});

  @override
  State<TabsClosableExample> createState() => _TabsClosableExampleState();
}

class _TabsClosableExampleState extends State<TabsClosableExample> {
  static const _files = ['main.dart', 'app.dart', 'theme.dart'];

  final _open = [..._files];
  int _index = 0;

  void _close(int index) {
    setState(() {
      _open.removeAt(index);
      if (index < _index || _index == _open.length) {
        _index = (_index - 1).clamp(0, _open.length);
      }
    });
  }

  void _reopen() {
    setState(() {
      _open
        ..clear()
        ..addAll(_files);
      _index = 0;
    });
  }

  @override
  Widget build(BuildContext context) {
    return Column(
      mainAxisSize: MainAxisSize.min,
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        if (_open.isEmpty)
          Button(
            variant: CoreButtonVariant.outline,
            onPressed: _reopen,
            child: const Text('Reopen files'),
          )
        else
          Tabs(
            tabs: [
              for (final (i, file) in _open.indexed) CoreTabItem(label: file, onClose: () => _close(i)),
            ],
            selectedIndex: _index,
            onChanged: (i) => setState(() => _index = i),
          ),
        const Gap.space16(),
        Text(
          _open.isEmpty ? 'All files closed.' : 'Editing ${_open[_index]}.',
        ).bodyMedium.onSurface,
      ],
    );
  }
}
Tabs(
  tabs: [
    for (final (i, file) in openFiles.indexed)
      CoreTabItem(label: file, onClose: () => close(i)),
  ],
  selectedIndex: index,
  onChanged: (i) => setState(() => index = i),
)
  • × 는 Button(variant: .ghost) 이고, 상자와 글리프는 CoreTabsStyle.closeButtonStyle(24 × 24 · 안쪽 여백 0 · 모서리 6) · closeIconStyle(12 · on-surface-subtle)이 정합니다. 라벨·배지와의 간격은 배지와 같은 labelBadgeGapStyle 입니다.
  • hover / pressed 상태 레이어는 × 를 포함한 탭 상자 전체에 칠해집니다.
  • 비활성 탭의 × 는 흐려지지 않고 그대로 동작합니다 — × 가 선택 영역 밖이라 탭의 비활성 상태가 닿지 않습니다.
  • onClose 가 없는 탭의 렌더는 바뀌지 않습니다.

비활성화 탭#

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

라벨은 disabled-content 토큰 색으로 그려집니다 — 선택되지 않은 일반 탭과는 다른 색입니다. underline variant 에서 비활성 탭이 선택된 상태라면(예: 현재 탭을 비활성화하는 흐름) 인디케이터도 primary 대신 같은 disabled-content 로 그려집니다 — 어떤 탭이 현재인지는 계속 보여주되, 활성화된 색으로 착각하게 두지 않습니다.

상태 레이어 (hover / pressed)#

활성화된 탭은 포인터가 올라가면 on-surface 8%, 누르면 16% 알파의 상태 레이어가 겹쳐 그려집니다 — 비활성 탭에는 적용되지 않습니다. Flutter 는 Clickable(onHover:, onPressedChange:) 로 감지하고, Web 은 hover:/active: CSS 의사 클래스로 처리합니다(자바스크립트 상태 없음). 닫을 수 있는 탭은 × 가 선택 영역 밖에 있으므로 상자에서 감지합니다 — Flutter 는 상자의 MouseRegion · Listener, Web 은 상자의 hover: / active: 입니다.

동작 스펙 (Behavior)#

탭 전환#

  • 탭 클릭 시 해당 콘텐츠 패널이 즉시 표시
  • onChanged 콜백으로 탭 전환 이벤트 감지
  • 활성 인디케이터는 CoreDuration.ms100 으로 부드럽게 전환
  • onClose 가 있는 탭의 × 를 누르면 onClose 만 호출되고 onChanged 는 호출되지 않습니다

스와이프#

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

테마 설정#

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

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

pill/underline 탭 바와 선택 이벤트만 필요하면 Tabs를 사용합니다. 탭과 boxed 콘텐츠 패널·닫기·재정렬을 묶으면 TabPane, 앱 목적지의 bar/rail/sidebar는 NavigationBar, 자유 뷰 슬라이드는 Switcher를 선택합니다. Tabs는 CoreTabItem.content를 렌더하지 않습니다.

패널 전환 없이 값 하나를 선택하면 RadioGroup, 선택 값 없이 공간적 포커스 이동만 구성하면 Subfocus를 사용합니다. 상단 드롭다운 내비게이션은 NavigationMenu, 번호 기반 데이터 페이지 이동은 Pagination로 구분합니다.

선택된 패널 하나를 교체하는 Tabs와 달리, 본문 흐름 안에서 각 섹션을 펼치고 접으려면 Accordion을 사용합니다.

✅ 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)#

역할#

FlutterWeb
탭 목록(루트) Semantics(container: true) — 역할도 이름도 없습니다 role="tablist"
탭 하나 Clickable 의 Semantics(button: true) 위에 컴포넌트가 Semantics(enabled:, selected:, button: true) — tab 역할이 아니라 button 으로 통보됩니다 Clickable 의 role="button" 을 caller attributes 가 role="tab" 으로 덮고, aria-selected="true" / "false" 를 함께 얹습니다
패널없음없음

Tabs 는 탭 바만 그립니다. 패널을 함께 그리는 TabPane 은 별도 컴포넌트이며 role="tabpanel" 도 그쪽에 있습니다 — Tabs 의 탭에는 패널을 가리키는 aria-controls 가 없습니다.

Web 루트의 role 은 caller attributes 병합 뒤에 기록되어 덮을 수 없지만, aria-label 은 강제하지 않으므로 호출부에서 attributes: {'aria-label': …} 로 탭 목록 이름을 붙일 수 있습니다.

<!-- Web 이 실제로 내보내는 구조 -->
<div role="tablist">
  <div role="tab" aria-selected="true"  tabindex="0"><span>탭 1</span></div>
  <div role="tab" aria-selected="false" tabindex="-1"><span>탭 2</span></div>
</div>

키보드#

키FlutterWeb
Tab 탭마다 개별 tab stop 탭 목록 전체가 tab stop 하나 — 선택된 탭으로 들어감
ArrowLeft / ArrowRight 프레임워크 방향성 포커스 이동 이전/다음 탭으로 포커스 이동 — 끝에서 반대편으로 순환
Home / End없음첫 탭 / 마지막 탭으로 포커스 이동
Enter / Space포커스된 탭 선택포커스된 탭 선택

Web 은 WAI-ARIA tabs 패턴의 수동 활성화를 따릅니다 — 화살표는 포커스만 옮기고 선택은 Enter/Space 로 합니다(Flutter 에서 화살표가 포커스만 옮기는 것과 같은 동작). 비활성 탭도 화살표로 닿지만 선택되지 않습니다. TabPane 도 같은 키를 따릅니다.

브라우저 검증 — 이 컴포넌트의 Web 키보드 이동은 야간 파리티 스윕의 키보드 프로브(tool/parity/keyboard_probe.dart)가 하이드레이션된 문서 사이트에서 실제로 키를 눌러, 포커스가 기대한 항목으로 옮겨 갔는지 확인합니다. 핸들러가 달렸는지가 아니라 포커스가 어디에 있는지를 봅니다.

스크린 리더#

  • 이름의 출처는 CoreTabItem.label 이며 필수 파라미터라 이름 없는 탭이 생기지 않습니다. 두 플랫폼 모두 렌더된 텍스트가 그대로 접근 가능한 이름이 됩니다.
  • 선택 상태: Flutter selected: true, Web aria-selected="true". 다만 Flutter 는 역할이 button 이라 "선택된 버튼" 으로, Web 은 "선택된 탭" 으로 읽혀 문구가 갈립니다.
  • 비활성 탭(CoreTabItem(enabled: false) 이거나 onChanged 가 없을 때): Flutter enabled: false, Web aria-disabled="true".
  • 닫기 ×(onClose 가 있는 탭): 아이콘뿐인 버튼이라 렌더 시점 로케일의 닫기 문구가 이름입니다 — Flutter Semantics(label:), Web aria-label. 탭과 별개의 버튼으로 읽힙니다.
  • 탭 목록 자체의 이름: 두 플랫폼 모두 기본값이 없습니다. Web 은 호출부에서 aria-label 을 얹을 수 있고, Flutter 는 바깥에서 Semantics 로 감싸야 합니다.
  • live region 없음 — 스와이프나 외부 코드로 selectedIndex 가 바뀌어도 그 변화 자체는 통보되지 않습니다.

포커스 관리#

  • 진입 / 이탈: 오버레이가 아니므로 마운트·언마운트 시 포커스를 옮기지 않습니다.
  • 트랩: 없습니다. Tab 은 마지막 탭을 지나 바깥으로 나갑니다.
  • tab stop 수: Flutter 는 활성 탭마다 하나, Web 은 탭 목록 전체에 하나입니다. Web 에서는 비활성 탭도 화살표로 닿지만 선택되지 않습니다. 닫을 수 있는 탭의 × 는 두 플랫폼 모두 그와 별개의 tab stop 입니다 — 화살표 이동에는 들어가지 않습니다.
  • 선택 시 포커스 이동 없음: onChanged 는 선택 인덱스만 바꾸고 포커스는 방금 누른 탭에 남습니다.
  • 포커스 표시: Clickable 이 소유합니다 — Flutter 는 FocusOutline, Web 은 :focus-visible 링입니다.

알려진 제약#

  • Flutter 탭이 tab 역할로 통보되지 않습니다. Web 은 role="tablist" / role="tab" / aria-selected 를 내보내지만 Flutter 쪽은 루트가 역할 없는 Semantics(container: true) 이고 탭은 Semantics(button: true, selected:) 입니다 — 선택 상태는 전달되나 "몇 개 중 몇 번째 탭" 같은 탭 목록 문맥은 전달되지 않습니다. 대안: 없습니다 — 두 시맨틱 모두 컴포넌트 내부에서 만들어져 호출부에 진입점이 없습니다.
  • Flutter 는 탭 전용 roving 탐색을 제공하지 않습니다. 화살표의 방향성 순회는 프레임워크 폴백이므로 Web 의 순환·Home/End 와 다릅니다.
  • 탭이 패널과 연결되어 있지 않습니다. Tabs 는 바만 그리므로 aria-controls / aria-labelledby 로 패널을 가리키는 배선이 없고, 스크린 리더 사용자가 탭에서 해당 패널로 바로 이동할 수 없습니다. 대안: 바와 패널을 함께 그리는 TabPane 을 쓰거나, 호출부에서 패널에 직접 role="tabpanel" 과 이름을 얹습니다.
  • 선택 상태는 채움 · 밑줄과 굵기로 구분됩니다. pill variant 는 활성 탭에만 배경 채움을 emit 하고, underline variant 는 비활성 탭에 border-transparent 를, 활성 탭에만 색이 있는 밑줄을 emit 합니다. 두 변형 모두 굵기(활성 600 · 비활성 500)가 달라, OS 의 색 강제(forced-colors) 모드에서 채움이 사라져도 그 구분이 남습니다. 상태 자체는 Web aria-selected / Flutter selected 로 계속 통보됩니다.
  • 스와이프로 탭을 넘기는 경로는 포인터 전용입니다. Flutter 는 가로 드래그, Web 은 touchstart / touchend 로만 동작합니다(Web 은 마우스 드래그로는 넘어가지 않습니다). 키보드 사용자는 Tab 으로 대상 탭까지 이동한 뒤 Enter / Space 로 선택하는 경로가 있으므로 기능 자체가 막히지는 않습니다.

크로스 플랫폼 차이점 (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이 변경됨

긴 번역과 확대 글자#

폭이 제한된 부모 안에서 scrollable: true를 사용하면 각 탭이 라벨, 배지, 닫기 버튼에 필요한 너비를 유지하고 가로로 스크롤됩니다. Flutter와 Web 모두 Tabs, Tabs.labels, copyWith에 같은 옵션을 제공합니다.

Tabs.labels(
  labels: const ['계정 및 개인정보 설정', '알림 및 접근성 환경설정'],
  selectedIndex: selectedIndex,
  onChanged: onChanged,
  scrollable: true,
)

이 모드에서는 expand를 함께 지정해도 균등 분할하지 않습니다. 가로 드래그는 스크롤에 사용되므로 swipeable에 의한 선택 전환은 중지됩니다. 키보드 포커스와 Enter/Space 선택은 유지됩니다. 기본값 false에서는 기존 폭·스타일·스와이프 동작이 유지됩니다. 긴 라벨을 expand만으로 나누면 라벨 고유 폭이 할당 폭보다 클 수 있으므로 좁은 화면에서는 스크롤 모드를 사용하세요.