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#
코드 보기
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,
],
);
}
}
구조 (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>> |
필수 | 탭 목록 |
selectedIndex | int | 필수 | 현재 선택된 탭 인덱스 |
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 동일한 데이터 타입입니다.
| 속성 | 타입 | 기본값 | 설명 |
|---|---|---|---|
label | String | 필수 | 탭에 표시되는 텍스트 |
enabled | bool | true | 선택 가능 여부 |
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)#
| 필드 | pill | underline |
|---|---|---|
activeIndicatorColor | surfaceRaised | primary |
disabledIndicatorColor | disabledContainer | disabledContent |
inactiveContentColor | onSurfaceSubtle | onSurfaceSubtle |
tabBarPadding | CoreEdgeInsets.all(CoreSpace.space2) | zero |
tabPadding |
CoreEdgeInsets.symmetric(horizontal: CoreSpace.space12) | zero |
backgroundColor | surfaceContainer | — |
activeFontWeight | semiBold | semiBold |
inactiveFontWeight | medium | medium |
tabSpacing | space2 | space20 |
sizeRungIsTrack | true | false |
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), 두 플랫폼이 변형 이름이 아니라 이 값을 읽습니다.
코드 보기
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 동일).
코드 보기
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 동일).
코드 보기
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 동일).
코드 보기
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)#
역할#
| Flutter | Web | |
|---|---|---|
| 탭 목록(루트) | 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>
키보드#
| 키 | Flutter | Web |
|---|---|---|
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, Webaria-selected="true". 다만 Flutter 는 역할이button이라 "선택된 버튼" 으로, Web 은 "선택된 탭" 으로 읽혀 문구가 갈립니다. -
비활성 탭(
CoreTabItem(enabled: false)이거나onChanged가 없을 때): Flutterenabled: false, Webaria-disabled="true". -
닫기 ×(
onClose가 있는 탭): 아이콘뿐인 버튼이라 렌더 시점 로케일의 닫기 문구가 이름입니다 — FlutterSemantics(label:), Webaria-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"과 이름을 얹습니다. -
선택 상태는 채움 · 밑줄과 굵기로 구분됩니다.
pillvariant 는 활성 탭에만 배경 채움을 emit 하고,underlinevariant 는 비활성 탭에border-transparent를, 활성 탭에만 색이 있는 밑줄을 emit 합니다. 두 변형 모두 굵기(활성 600 · 비활성 500)가 달라, OS 의 색 강제(forced-colors) 모드에서 채움이 사라져도 그 구분이 남습니다. 상태 자체는 Webaria-selected/ Flutterselected로 계속 통보됩니다. -
스와이프로 탭을 넘기는 경로는 포인터 전용입니다. Flutter 는 가로 드래그, Web 은
touchstart/touchend로만 동작합니다(Web 은 마우스 드래그로는 넘어가지 않습니다). 키보드 사용자는Tab으로 대상 탭까지 이동한 뒤Enter/Space로 선택하는 경로가 있으므로 기능 자체가 막히지는 않습니다.
크로스 플랫폼 차이점 (Platform Differences)#
Tabs / TabPane 은 Flutter / Web 에서 동일한 named properties API
를 사용합니다. 파라미터 이름·구조·동작이 양쪽 동일합니다.
| 항목 | Flutter | Web |
|---|---|---|
| 드래그 정렬 | Draggable / DragTarget | HTML5 drag-and-drop |
| 가로 스크롤 | FadeScroll + SingleChildScrollView |
overflow-x-auto |
| 스와이프 | GestureDetector.onHorizontalDragEnd |
touchstart / touchend |
관련 컴포넌트 (Related Components)#
- 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만으로 나누면
라벨 고유 폭이 할당 폭보다 클 수 있으므로 좁은 화면에서는 스크롤 모드를 사용하세요.