TabPane#
박스형 탭 스트립과 각 탭이 소유한 콘텐츠 패널을 하나로 묶은 자체 완결형 탭 카드입니다. 탭 바만 제공하는 Tabs 와 달리,
CoreTabItem.content 슬롯의 위젯을 아래 패널에 함께 렌더합니다.
Live Preview#
class TabPaneExample extends StatefulComponent {
const TabPaneExample({super.key});
@override
State<TabPaneExample> createState() => _TabPaneExampleState();
}
class _TabPaneExampleState extends State<TabPaneExample> {
int _index = 0;
@override
Component build(BuildContext context) {
return TabPane(
tabs: [
CoreTabItem(
label: 'Account',
content: div(
[Text('Manage your account settings.').bodyMedium.onSurface],
classes: 'p-${CoreSpace.scale.space16}',
),
),
CoreTabItem(
label: 'Password',
content: div(
[Text('Change your password here.').bodyMedium.onSurface],
classes: 'p-${CoreSpace.scale.space16}',
),
),
],
selectedIndex: _index,
onChanged: (index) => setState(() => _index = index),
);
}
}
class TabPaneExample extends StatefulWidget {
const TabPaneExample({super.key});
@override
State<TabPaneExample> createState() => _TabPaneExampleState();
}
class _TabPaneExampleState extends State<TabPaneExample> {
int _index = 0;
@override
Widget build(BuildContext context) {
return TabPane(
tabs: [
CoreTabItem(
label: 'Account',
content: Padding(
padding: const EdgeInsets.all(CoreSpace.space16),
child: Text('Manage your account settings.').bodyMedium.onSurface,
),
),
CoreTabItem(
label: 'Password',
content: Padding(
padding: const EdgeInsets.all(CoreSpace.space16),
child: Text('Change your password here.').bodyMedium.onSurface,
),
),
],
selectedIndex: _index,
onChanged: (i) => setState(() => _index = i),
);
}
}
class TabPaneChainExample extends StatefulComponent {
const TabPaneChainExample({super.key});
@override
State<TabPaneChainExample> createState() => _TabPaneChainExampleState();
}
class _TabPaneChainExampleState extends State<TabPaneChainExample> {
int _index = 0;
@override
Component build(BuildContext context) {
return div(
[
// 토큰-정확 getter combo — 이름이 곧 값 (getter 이름 = Core 토큰 상수 1:1).
TabPane(
tabs: [
CoreTabItem(
label: 'Account',
content: div(
[Text('Manage your account settings.').bodyMedium.onSurface],
classes: 'p-${CoreSpace.scale.space16}',
),
),
CoreTabItem(
label: 'Password',
content: div(
[Text('Change your password here.').bodyMedium.onSurface],
classes: 'p-${CoreSpace.scale.space16}',
),
),
],
selectedIndex: _index,
onChanged: (index) => setState(() => _index = index),
).radius16.primary,
const Gap.space12(),
// withStyle full-control — getter 가 없는 필드(borderWidth)까지 한 번에.
TabPane(
tabs: [
CoreTabItem(
label: 'Account',
content: div(
[Text('Manage your account settings.').bodyMedium.onSurface],
classes: 'p-${CoreSpace.scale.space16}',
),
),
CoreTabItem(
label: 'Password',
content: div(
[Text('Change your password here.').bodyMedium.onSurface],
classes: 'p-${CoreSpace.scale.space16}',
),
),
],
selectedIndex: _index,
onChanged: (index) => setState(() => _index = index),
).withStyle(
const CoreTabPaneStyle(
backgroundColor: CoreColor.token(CoreColors.tertiary),
borderRadius: CoreBorderRadius.all(CoreRadius.radius24),
borderWidth: CoreStrokeWidth.stroke2,
),
),
],
classes: 'flex flex-col items-start',
);
}
}
class TabPaneChainExample extends StatefulWidget {
const TabPaneChainExample({super.key});
@override
State<TabPaneChainExample> createState() => _TabPaneChainExampleState();
}
class _TabPaneChainExampleState extends State<TabPaneChainExample> {
int _index = 0;
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 토큰-정확 getter combo — 이름이 곧 값 (getter 이름 = Core 토큰 상수 1:1).
TabPane(
tabs: [
CoreTabItem(
label: 'Account',
content: Padding(
padding: const EdgeInsets.all(CoreSpace.space16),
child: Text(
'Manage your account settings.',
).bodyMedium.onSurface,
),
),
CoreTabItem(
label: 'Password',
content: Padding(
padding: const EdgeInsets.all(CoreSpace.space16),
child: Text('Change your password here.').bodyMedium.onSurface,
),
),
],
selectedIndex: _index,
onChanged: (i) => setState(() => _index = i),
).radius16.primary,
const Gap.space12(),
// withStyle full-control — getter 가 없는 필드(borderWidth)까지 한 번에.
TabPane(
tabs: [
CoreTabItem(
label: 'Account',
content: Padding(
padding: const EdgeInsets.all(CoreSpace.space16),
child: Text(
'Manage your account settings.',
).bodyMedium.onSurface,
),
),
CoreTabItem(
label: 'Password',
content: Padding(
padding: const EdgeInsets.all(CoreSpace.space16),
child: Text('Change your password here.').bodyMedium.onSurface,
),
),
],
selectedIndex: _index,
onChanged: (i) => setState(() => _index = i),
).withStyle(
const CoreTabPaneStyle(
backgroundColor: CoreColor.token(CoreColors.tertiary),
borderRadius: CoreBorderRadius.all(CoreRadius.radius24),
borderWidth: CoreStrokeWidth.stroke2,
),
),
],
);
}
}
사용 시기 (When to Use)#
이 컴포넌트를 사용하세요:
- 탭과 그 콘텐츠 패널을 한 덩어리(카드)로 함께 보여줄 때
- 각 탭이 자체 콘텐츠를 소유하고 드래그로 순서를 바꿀 수 있어야 할 때
대신 다른 컴포넌트를 사용하세요:
Tabs: 탭 바만 필요하고 콘텐츠 영역을 직접 배치할 때 (pill / underline)Accordion: 모든 섹션을 한 페이지에서 펼치고 접을 때
기본 사용법 (Basic Usage)#
TabPane(
tabs: [
CoreTabItem(label: 'Account', content: accountPanel),
CoreTabItem(label: 'Password', content: passwordPanel),
],
selectedIndex: index,
onChanged: (i) => setState(() => index = i),
)
빠른 오버라이드 (Chain)#
이미 만든 TabPane 인스턴스에 스타일을 빠르게 덧붙이고 싶다면 withStyle 체인을 쓸 수 있습니다. 생성자의 style 슬롯 인자와 동일하게 동작하지만, 이미 구성된 위젯 위에서 바로 이어 쓸 수 있습니다.
.radius24처럼 Core 토큰 상수 이름과 똑같은 이름의 getter도 있습니다 — withStyle을 한 번 더 줄인 sugar로, 이름이 곧 값이라(radius24 ==
CoreRadius.radius24) 어느 컴포넌트에서 써도 뜻이 갈리지 않습니다.
class TabPaneChainExample extends StatefulWidget {
const TabPaneChainExample({super.key});
@override
State<TabPaneChainExample> createState() => _TabPaneChainExampleState();
}
class _TabPaneChainExampleState extends State<TabPaneChainExample> {
int _index = 0;
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 토큰-정확 getter combo — 이름이 곧 값 (getter 이름 = Core 토큰 상수 1:1).
TabPane(
tabs: [
CoreTabItem(
label: 'Account',
content: Padding(
padding: const EdgeInsets.all(CoreSpace.space16),
child: Text(
'Manage your account settings.',
).bodyMedium.onSurface,
),
),
CoreTabItem(
label: 'Password',
content: Padding(
padding: const EdgeInsets.all(CoreSpace.space16),
child: Text('Change your password here.').bodyMedium.onSurface,
),
),
],
selectedIndex: _index,
onChanged: (i) => setState(() => _index = i),
).radius16.primary,
const Gap.space12(),
// withStyle full-control — getter 가 없는 필드(borderWidth)까지 한 번에.
TabPane(
tabs: [
CoreTabItem(
label: 'Account',
content: Padding(
padding: const EdgeInsets.all(CoreSpace.space16),
child: Text(
'Manage your account settings.',
).bodyMedium.onSurface,
),
),
CoreTabItem(
label: 'Password',
content: Padding(
padding: const EdgeInsets.all(CoreSpace.space16),
child: Text('Change your password here.').bodyMedium.onSurface,
),
),
],
selectedIndex: _index,
onChanged: (i) => setState(() => _index = i),
).withStyle(
const CoreTabPaneStyle(
backgroundColor: CoreColor.token(CoreColors.tertiary),
borderRadius: CoreBorderRadius.all(CoreRadius.radius24),
borderWidth: CoreStrokeWidth.stroke2,
),
),
],
);
}
}
class TabPaneChainExample extends StatefulComponent {
const TabPaneChainExample({super.key});
@override
State<TabPaneChainExample> createState() => _TabPaneChainExampleState();
}
class _TabPaneChainExampleState extends State<TabPaneChainExample> {
int _index = 0;
@override
Component build(BuildContext context) {
return div(
[
// 토큰-정확 getter combo — 이름이 곧 값 (getter 이름 = Core 토큰 상수 1:1).
TabPane(
tabs: [
CoreTabItem(
label: 'Account',
content: div(
[Text('Manage your account settings.').bodyMedium.onSurface],
classes: 'p-${CoreSpace.scale.space16}',
),
),
CoreTabItem(
label: 'Password',
content: div(
[Text('Change your password here.').bodyMedium.onSurface],
classes: 'p-${CoreSpace.scale.space16}',
),
),
],
selectedIndex: _index,
onChanged: (index) => setState(() => _index = index),
).radius16.primary,
const Gap.space12(),
// withStyle full-control — getter 가 없는 필드(borderWidth)까지 한 번에.
TabPane(
tabs: [
CoreTabItem(
label: 'Account',
content: div(
[Text('Manage your account settings.').bodyMedium.onSurface],
classes: 'p-${CoreSpace.scale.space16}',
),
),
CoreTabItem(
label: 'Password',
content: div(
[Text('Change your password here.').bodyMedium.onSurface],
classes: 'p-${CoreSpace.scale.space16}',
),
),
],
selectedIndex: _index,
onChanged: (index) => setState(() => _index = index),
).withStyle(
const CoreTabPaneStyle(
backgroundColor: CoreColor.token(CoreColors.tertiary),
borderRadius: CoreBorderRadius.all(CoreRadius.radius24),
borderWidth: CoreStrokeWidth.stroke2,
),
),
],
classes: 'flex flex-col items-start',
);
}
}
Props / Parameters#
| 속성 | 타입 | 기본값 | 설명 |
|---|---|---|---|
tabs |
List<CoreTabItem> |
필수 |
탭 목록 (
content
슬롯이 콘텐츠 패널에 표시되고,
leading
·
badge
·
trailing
슬롯이
탭 하나 안에서
라벨 앞뒤로 그려짐 —
Tabs
의
CoreTabItem
표 참조)
|
selectedIndex | int | 필수 | 현재 포커스된 탭 인덱스 |
onChanged |
CoreValueChanged<int>? |
null |
탭 변경 콜백 |
reorderable |
bool |
false |
드래그로 탭 순서 변경 |
onReorder |
void Function(List<CoreTabItem>)? |
null |
탭 순서 변경 콜백 |
leading |
List<Widget> |
[] |
탭 바 앞쪽 위젯 (스트립 전체의 왼쪽 — 탭 하나 안의 CoreTabItem.leading 과 다른 자리입니다) |
trailing |
List<Widget> |
[] |
탭 바 뒤쪽 위젯 (스트립 전체의 오른쪽 — CoreTabItem.trailing 과 다른 자리입니다) |
tabPaneStyle |
CoreTabPaneStyle? |
null |
인스턴스 스타일 |
스타일 시스템 (Style System)#
모든 chrome / dimensional 오버라이드는 CoreTabPaneStyle 단일 슬롯으로 흐릅니다. behaviour (tabs
/ selectedIndex / onChanged / reorderable) 는 위젯/컴포넌트 파라미터로 직접 전달합니다.
Resolve chain#
CoreTabPaneStyle.defaultX // 디자인 시스템 기본값
→ CoreTabPaneTheme.style // 프로젝트 공통
→ widget.tabPaneStyle // 인스턴스별
CoreTabPaneStyle 필드#
CoreTabPaneStyle 은 backgroundColor / borderColor / borderRadius
/ borderWidth / barHeight / tabPadding / tabSpacing
/ dragOpacity / contentTextStyle / clickableStyle(합성된 boxed-tab
Clickable 로 raw-forward) / labelBadgeGapStyle(탭 내부 한 걸음 — 라벨과 그 옆에 걸린 것 사이 간격, 기본
CoreSpace.space4 — 합성된 Gap 으로 raw-forward. CoreTabItem 의 badge
· leading · trailing 이 같은 값을 씁니다: Figma auto-layout 프레임은 itemSpacing
을 하나만 갖기 때문에, 세 개의 서로 다른 1-off 간격은 캔버스가 잡지 못하는 손잡이입니다) 를 가집니다.
동작 스펙 (Behavior)#
탭 전환#
- 탭 클릭 시 해당 콘텐츠 패널이 즉시 표시
onChanged콜백으로 탭 전환 이벤트 감지-
포커스 탭은 패널과 외곽선을 공유합니다 — 탭 아래 선이 없고, 스트립이 패널 윗선 위로
borderWidth만큼 겹쳐 그 아래 선을 가립니다. 스트립 양끝 inset 은borderRadius의 위 모서리 반경(barHeight상한)에서 파생되며 별도 필드가 아닙니다
드래그 정렬#
reorderable: true일 때 탭을 드래그하여 순서 변경onReorder콜백이 재정렬된CoreTabItem목록을 전달- 탭이 많아지면 탭 바가 가로 스크롤되며 가장자리에 페이드 효과 표시
사용 가이드라인 (Usage Guidelines)#
✅ Do#
각 CoreTabItem 의 content 슬롯을 채우기
TabPane(
tabs: [
CoreTabItem(label: 'Account', content: accountPanel),
CoreTabItem(label: 'Password', content: passwordPanel),
],
selectedIndex: index,
onChanged: (i) => setState(() => index = i),
)
content 슬롯은 TabPane 이 콘텐츠 패널에 실제로 렌더하는 값입니다 — Tabs 는 같은 슬롯을 무시하므로, TabPane 을 쓰면서 content 를 비워두면 탭 바만 있고 패널이 빈 카드가 됩니다.
❌ Don't#
reorderable: true 만 켜고 onReorder 콜백을 생략하지 않기
// ❌ onReorder 없음 — 드래그 자체가 동작하지 않음
TabPane(
tabs: tabs,
selectedIndex: index,
reorderable: true,
)
reorderable 과 onReorder 둘 다 있어야 드래그 인터랙션이 활성화됩니다 — onReorder 가 null 이면 reorderable: true 를 줘도 탭이 드래그 대상이 되지 않습니다.
접근성 (Accessibility)#
-
Web:
role="tab",role="tablist",role="tabpanel",aria-selected적용 - 선택된 탭은 위·좌·우 테두리를 가진 박스형 chrome 으로 구분되고, 아래는 열려 있어 탭의 채움이 패널 윗선을 덮으며 한 외곽선으로 이어집니다. 탭 스트립은 패널 모서리 반경만큼 안쪽에서 시작하므로 첫 탭이 패널의 둥근 모서리 위에 앉지 않습니다 (Flutter · Web · Figma 동일)
크로스 플랫폼 차이점 (Platform Differences)#
TabPane 은 Flutter / Web 에서 동일한 named properties API 를 사용합니다. 파라미터 이름·구조·동작이 양쪽 동일합니다.