TabPane | CoUI
LogoCoUI

TabPane

박스형 탭 + 통합 콘텐츠 패널

TabPane#

박스형 탭 스트립과 각 탭이 소유한 콘텐츠 패널을 하나로 묶은 자체 완결형 탭 카드입니다. 탭 바만 제공하는 Tabs 와 달리, CoreTabItem.content 슬롯의 위젯을 아래 패널에 함께 렌더합니다.

Live Preview#

사용 시기 (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 슬롯이 탭 하나 안에서 라벨 앞뒤로 그려짐 — TabsCoreTabItem 표 참조)
selectedIndexint필수현재 포커스된 탭 인덱스
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 필드#

CoreTabPaneStylebackgroundColor / borderColor / borderRadius / borderWidth / barHeight / tabPadding / tabSpacing / dragOpacity / contentTextStyle / clickableStyle(합성된 boxed-tab Clickable 로 raw-forward) / labelBadgeGapStyle(탭 내부 한 걸음 — 라벨과 그 옆에 걸린 것 사이 간격, 기본 CoreSpace.space4 — 합성된 Gap 으로 raw-forward. CoreTabItembadge · leading · trailing 이 같은 값을 씁니다: Figma auto-layout 프레임은 itemSpacing 을 하나만 갖기 때문에, 세 개의 서로 다른 1-off 간격은 캔버스가 잡지 못하는 손잡이입니다) 를 가집니다.

동작 스펙 (Behavior)#

탭 전환#

  • 탭 클릭 시 해당 콘텐츠 패널이 즉시 표시
  • onChanged 콜백으로 탭 전환 이벤트 감지
  • 포커스 탭은 패널과 외곽선을 공유합니다 — 탭 아래 선이 없고, 스트립이 패널 윗선 위로 borderWidth 만큼 겹쳐 그 아래 선을 가립니다. 스트립 양끝 inset 은 borderRadius 의 위 모서리 반경(barHeight 상한)에서 파생되며 별도 필드가 아닙니다

드래그 정렬#

  • reorderable: true 일 때 탭을 드래그하여 순서 변경
  • onReorder 콜백이 재정렬된 CoreTabItem 목록을 전달
  • 탭이 많아지면 탭 바가 가로 스크롤되며 가장자리에 페이드 효과 표시

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

✅ Do#

CoreTabItemcontent 슬롯을 채우기

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,
)

reorderableonReorder 둘 다 있어야 드래그 인터랙션이 활성화됩니다 — onReordernull 이면 reorderable: true 를 줘도 탭이 드래그 대상이 되지 않습니다.

접근성 (Accessibility)#

  • Web: role="tab", role="tablist", role="tabpanel", aria-selected 적용
  • 선택된 탭은 위·좌·우 테두리를 가진 박스형 chrome 으로 구분되고, 아래는 열려 있어 탭의 채움이 패널 윗선을 덮으며 한 외곽선으로 이어집니다. 탭 스트립은 패널 모서리 반경만큼 안쪽에서 시작하므로 첫 탭이 패널의 둥근 모서리 위에 앉지 않습니다 (Flutter · Web · Figma 동일)

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

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

  • Tabs — 콘텐츠 패널 없이 탭 바만 (pill / underline)
  • Accordion — 펼침/접힘 섹션