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

ToggleGroup

상호 배타적인 옵션을 한 트랙에 묶고 선택한 칸을 위로 띄우는 묶음 토글

목차

옵션 여럿을 한 트랙에 묶어 하나만 고르게 하는 묶음 토글입니다. 트랙은 surface-container-high, 선택한 칸은 surface-raised 면으로 떠올라 가장 작은 그림자 단계(CoreShadow.xs)를 갖습니다. Flutter/Web 양쪽에서 같은 API(ToggleGroup)를 제공합니다.

필터의 세그먼트, 시간 선택기의 AM / PM, 색 선택기의 모드 탭이 같은 모양을 쓰도록 한 곳에 둔 부품입니다.

ToggleGroup 은 controlled 컴포넌트입니다 — 선택값은 부모가 value 로 소유하고 onChanged 로 갱신합니다. onChanged 가 없거나 enabled: false 이면 입력을 받지 않고 비활성 모양으로 그립니다.

기본 사용법 (Basic Usage)#

ToggleGroup<String>(
  value: _selected,
  onChanged: (value) => setState(() => _selected = value),
  items: const [
    CoreToggleGroupItem(value: 'am', label: 'AM'),
    CoreToggleGroupItem(value: 'pm', label: 'PM'),
  ],
)
ToggleGroup<String>(
  value: _selected,
  onChanged: (value) => setState(() => _selected = value),
  items: const [
    CoreToggleGroupItem(value: 'am', label: 'AM'),
    CoreToggleGroupItem(value: 'pm', label: 'PM'),
  ],
)

Live Preview#

구조 (Anatomy)#

ToggleGroup
 └─ track   (surface-container-high · 모서리 10(md) / 8(sm) · 안쪽 여백 4(md) / 3(sm))
      └─ segment × N   (높이 32(md) / 26(sm) · 최소 폭 = 높이 · 모서리 6 · 간격 2)
           ├─ icon?    (CoreToggleGroupItem.icon)
           └─ label    (CoreToggleGroupItem.label — showLabel: false 면 접근성 이름으로만 남음)

선택한 칸만 surface-raised 면과 그림자를 가지고, 나머지 칸은 채움이 없어 트랙이 그대로 보입니다.

사용 시기 (When to Use)#

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

  • 두세 개의 옵션 중 하나를 바로 바꾸는 자리 (AM / PM, 보기 방식, 모드 탭)
  • 옵션이 항상 화면에 모두 보여야 할 때

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

  • Filter: 초기화 버튼이 붙은 목록 필터
  • Tabs: 선택에 따라 콘텐츠 패널이 바뀔 때
  • RadioGroup: 폼 안의 라디오 입력 계약이 필요할 때
  • ChipGroup: 여러 개를 고를 때

Props / Parameters#

T 는 선택 값, W 는 Flutter Widget / Web Jaspr Component 입니다.

속성타입기본값설명
items List<CoreToggleGroupItem<T, W>> 필수 옵션 목록 (1개 이상)
value T? 필수 현재 선택 값. null 이면 아무 칸도 선택하지 않습니다.
onChanged void Function(T)? 필수 사용자가 칸을 고른 값을 알립니다. null 이면 입력을 받지 않습니다.
enabled bool? null null 이면 onChanged != null. 명시하면 그 값이 이깁니다.
size CoreComponentSize md 크기 단계. md 는 칸 32 · 트랙 40, sm 은 칸 26 · 트랙 32 입니다. sm 이 아닌 다른 값은 md 로 읽습니다. 판(시간 선택기의 AM / PM, 색 선택기의 모드 탭) 안에서는 sm 을 씁니다.
direction CoreAxis horizontal 칸을 놓는 축. horizontal 은 가로 한 줄, vertical 은 세로 한 줄(AM 위, PM 아래)입니다. 칸 사이 간격 · 트랙 안쪽 여백 · 칸 값은 두 방향이 같고, 세로에서는 칸이 가장 넓은 칸의 폭( fillWidth 이면 부모 폭)으로 늘어나고 내용은 가운데입니다.
fillWidth bool false 켜면 트랙이 부모가 주는 폭을 채우고 칸이 그 폭을 똑같이 나눠 가집니다(칸 안 내용은 가운데). 판 안 모드 탭처럼 아래 폭 가득한 영역과 가장자리를 맞출 때 씁니다. 끄면 트랙이 칸 내용만큼만 차지합니다.
trailing W? null 트랙 안, 마지막 칸 뒤에 놓는 호출자 콘텐츠(예: Filter 의 구분선과 reset 버튼). 칸이 아니므로 선택 상태와 무관하고, 칸 사이 간격( itemSpacing )이 마지막 칸과 이 슬롯 사이에도 적용됩니다.
toggleGroupStyle CoreToggleGroupStyle? null 스타일 단일 슬롯

CoreToggleGroupItem#

속성타입기본값설명
value T — 이 칸이 나타내는 값. value 와 == 로 비교해 선택 여부를 정합니다.
label String — 칸의 라벨. showLabel: false 여도 접근성 이름( aria-label / Semantics(label:) )으로 남습니다.
icon W? null 라벨 앞에 그리는 호출자 제공 아이콘. 상태별 색·크기는 iconStyle / selectedIconStyle / disabledIconStyle 이 정합니다.
showLabel bool true 라벨을 그릴지 여부. icon != null && showLabel == false 이면 아이콘 전용 정사각 칸이 됩니다.

스타일 시스템 (Style System)#

모든 시각 chrome 은 단일 toggleGroupStyle: CoreToggleGroupStyle? 슬롯으로 들어갑니다.

ToggleGroup<String>(
  value: _selected,
  onChanged: handleChanged,
  items: items,
  toggleGroupStyle: const CoreToggleGroupStyle(
    borderRadius: CoreBorderRadius.all(CoreRadius.radius16),
    itemBorderRadius: CoreBorderRadius.all(CoreRadius.radius12),
  ),
)

CoreToggleGroupStyle 필드#

필드타입설명
backgroundColorCoreColor?Fill of the track.
selectedColor CoreColor? Fill of the chosen segment.
disabledBackgroundColor CoreColor? Fill of the track of a disabled group.
disabledSelectedColor CoreColor? Fill of the chosen segment of a disabled group.
selectedShadow List<CoreShadowLayer>? Shadow under the chosen segment. An empty list paints none.
borderRadius CoreBorderRadius? Track corner radius.
itemBorderRadius CoreBorderRadius? Per-segment corner radius.
padding CoreEdgeInsets? Inset between the track edge and the segments.
itemPadding CoreEdgeInsets? Padding inside each segment — its uniform inset around [icon][label box].
labelPadding CoreEdgeInsets? Inset of the label box inside a segment (painted only when the item shows its label).
itemHeight double? Fixed segment height (logical px); content centres vertically.
itemSpacing double? Spacing between segments (logical px).
selectedFontWeight int? Font weight of the chosen segment's label (CSS font-weight integer).
transitionDuration Duration? Chosen-state colour transition duration.
stateLayerColor CoreColor? Colour of the state layer laid over a hovered segment.
stateLayer CoreStateLayer? Weights of the segment state layer (only hover is read).
labelTextStyle CoreTextStyle? Label text style of unchosen segments.
selectedLabelTextStyle CoreTextStyle? Label text style of the chosen segment.
disabledLabelTextStyle CoreTextStyle? Label text style of a disabled group, chosen and unchosen alike.
iconStyle CoreIconStyle? Icon style of an unchosen segment's caller-supplied icon.
selectedIconStyle CoreIconStyle? Icon style of the chosen segment's caller-supplied icon.
disabledIconStyle CoreIconStyle? Icon style of a disabled group's icons, chosen and unchosen alike.
clickableStyle CoreClickableStyle? Nested [CoreClickableStyle] for the composed per-segment Clickable (press scale / durations / focus ring), merged on top of [defaultClickableStyle] and raw-forwarded.

Resolve chain#

디자인 시스템 기본값 → CoreToggleGroupTheme.style → 위젯 toggleGroupStyle. 변형(variant) 축이 없어 variantStyles 도 없습니다.

테마로 덮어쓰기#

CoreComponentTheme(
  toggleGroup: CoreToggleGroupTheme(
    style: CoreToggleGroupStyle(
      backgroundColor: CoreColor.token(CoreColors.surfaceContainerHigh),
    ),
  ),
)

빠른 오버라이드 (Chain)#

이미 만든 ToggleGroup 인스턴스에 스타일을 이어 붙이려면 withStyle 체인을 씁니다. 생성자의 toggleGroupStyle 인자와 같게 동작합니다.

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

  @override
  State<ToggleGroupChainExample> createState() => _ToggleGroupChainExampleState();
}

class _ToggleGroupChainExampleState extends State<ToggleGroupChainExample> {
  String? _selected = 'am';

  void handleChanged(String value) {
    setState(() => _selected = value);
  }

  @override
  Widget build(BuildContext context) {
    // withStyle full-control — 트랙 모서리와 선택 면 그림자를 한 번에.
    return ToggleGroup<String>(
      value: _selected,
      onChanged: handleChanged,
      items: const [
        CoreToggleGroupItem(value: 'am', label: 'AM'),
        CoreToggleGroupItem(value: 'pm', label: 'PM'),
      ],
    ).withStyle(
      const CoreToggleGroupStyle(
        borderRadius: CoreBorderRadius.all(CoreRadius.radius16),
        itemBorderRadius: CoreBorderRadius.all(CoreRadius.radius12),
        selectedShadow: CoreShadow.none,
      ),
    );
  }
}
class ToggleGroupChainExample extends StatefulComponent {
  const ToggleGroupChainExample({super.key});

  @override
  State<ToggleGroupChainExample> createState() => _ToggleGroupChainExampleState();
}

class _ToggleGroupChainExampleState extends State<ToggleGroupChainExample> {
  String? _selected = 'am';

  void handleChanged(String value) {
    setState(() => _selected = value);
  }

  @override
  Component build(BuildContext context) {
    // withStyle full-control — 트랙 모서리와 선택 면 그림자를 한 번에.
    return ToggleGroup<String>(
      value: _selected,
      onChanged: handleChanged,
      items: const [
        CoreToggleGroupItem(value: 'am', label: 'AM'),
        CoreToggleGroupItem(value: 'pm', label: 'PM'),
      ],
    ).withStyle(
      const CoreToggleGroupStyle(
        borderRadius: CoreBorderRadius.all(CoreRadius.radius16),
        itemBorderRadius: CoreBorderRadius.all(CoreRadius.radius12),
        selectedShadow: CoreShadow.none,
      ),
    );
  }
}

변형 (Variants)#

세로#

direction: CoreAxis.vertical 이면 칸을 위에서 아래로 놓습니다(AM 위, PM 아래). sm 두 칸은 3 + 26 + 2 + 26 + 3 = 60 으로 시간 선택기의 시 · 분 칸 높이와 같아, 시간 선택기의 AM / PM 이 이 모양을 칸 옆에 위쪽 맞춤으로 둡니다.

비활성#

onChanged 를 주지 않거나 enabled: false 로 끄면 평평한 비활성 모양이 됩니다.

  • 트랙은 disabled-container
  • 선택한 칸은 surface-container-high 로 가라앉고 그림자가 없습니다
  • 글자와 아이콘은 disabled-content (선택 여부와 무관). 선택한 칸의 굵기는 유지됩니다.

아이콘 칸#

ToggleGroup<String>(
  value: _selected,
  onChanged: handleChanged,
  items: const [
    CoreToggleGroupItem(value: 'grid', label: 'Grid', icon: Icon(LucideIcons.layoutGrid)),
    CoreToggleGroupItem(value: 'list', label: 'List', icon: Icon(LucideIcons.list), showLabel: false),
  ],
)

동작 스펙 (Behavior)#

  • value == item.value 로 선택을 표시합니다. 칸을 고르면 onChanged(item.value) 만 호출하고, 부모가 값을 갱신해야 선택이 바뀝니다.
  • 칸 하나를 개별로 비활성화하는 플래그는 없습니다. 고를 수 없는 값은 items 에서 뺍니다.

시각#

  • 트랙 surface-container-high, 모서리 10, 안쪽 여백 3 (4px 격자 밖의 값이라 상수 하나로만 적혀 있고, Web 에서는 밀도 축이 움직이지 않는 길이로 나갑니다)
  • 선택한 칸은 surface-raised + CoreShadow.xs, 모서리 8, 세미볼드 라벨
  • 선택이 바뀔 때 150ms 색·그림자 전환
  • 포인터가 올라간 칸 위에 공통 상태 레이어가 깔립니다 — on-surface(stateLayerColor)를 hover 가중치(stateLayer.hover)로 칸 모양 그대로 덮습니다. 선택한 칸도 면 위에 같은 레이어를 받고(면 색과 그림자는 그대로), 비활성 그룹에는 없습니다
  • 판(surface-bright) 위에서 surface-container 는 판과 같은 값이므로 트랙은 한 단계 위 토큰(surface-container-high)을 씁니다.

레이아웃#

  • 칸은 가로로 itemSpacing(2) 간격, 높이는 크기 단계가 정한 itemHeight 로 고정이고, 칸 안 내용은 가로 · 세로 모두 가운데입니다. 트랙 높이는 안쪽 여백 + 칸 + 안쪽 여백 이라 md(4 + 32 + 4)는 40 으로 같은 줄의 md TextField · Button 과, sm(3 + 26 + 3)은 32 로 sm 컨트롤과 높이가 맞습니다.
  • 크기 단계가 바꾸는 값: 트랙 안쪽 여백(4 / 3) · 트랙 모서리(10 / 8) · 칸 높이(32 / 26) · 칸 좌우 여백(2 / 0) · 라벨 글자 역할(labelLarge / labelMedium). 칸 모서리 6 · 라벨 박스 좌우 여백 8 · 아이콘 크기 16 은 두 단계가 같습니다. 칸 모서리 6 은 md 트랙 10 에서 안쪽 여백 4 를 뺀 값이라 트랙과 칸이 같은 중심을 씁니다.
  • 칸은 높이보다 좁아지지 않습니다(최소 폭 = 칸 높이) — 아이콘만 있는 칸은 md 32×32 · sm 26×26 정사각형이고, 높이보다 넓은 라벨 칸은 제 폭 그대로입니다.
  • sm 두 칸은 3 + 26 + 2 + 26 + 3 = 60 으로, 시간 선택기의 시 · 분 칸 높이와 맞습니다. 그래서 시간 선택기의 AM / PM 과 색 선택기의 모드 탭이 sm 을 씁니다. 세로 배치(direction: .vertical)에서도 같은 60 입니다 — 시간 선택기의 AM / PM 은 판 폭을 좁게 두려고 시 · 분 칸 옆에 세로로, 칸 위쪽에 맞춰 놓습니다.
  • direction: .vertical 이면 칸이 위에서 아래로 놓이고 칸 사이 간격 itemSpacing(2) · 트랙 안쪽 여백은 가로와 같습니다. 칸은 가장 넓은 칸의 폭으로 맞춰지고(Flutter 는 Column 의 stretch, Web 은 flex-col + items-stretch) 내용은 가운데입니다. 선택한 칸의 면 · 그림자는 칸마다 칠해지므로 방향과 무관하게 같은 칸을 따라갑니다. 세로 sm 두 칸은 3 + 26 + 2 + 26 + 3 = 60, 세로 md 두 칸은 4 + 32 + 2 + 32 + 4 = 74 입니다. 방향키는 위 · 아래 · 좌 · 우 모두 칸을 옮깁니다.
  • Figma 의 ToggleGroup 은 Size=md | sm 축이 Width=Hug | Fill · Orientation=Horizontal | Vertical 축과 직교로 있고, 판 안 인스턴스는 Size=sm 입니다.
  • 칸의 폭은 가장 굵은(선택) 글자 폭으로 잡혀, 선택이 옮겨 가도 그룹의 폭이 변하지 않습니다.
  • fillWidth: true 이면 트랙이 부모 폭을 채우고 칸이 폭을 똑같이 나눠 가집니다(예: 3칸이면 3등분, 칸 안 라벨은 가운데). 칸 사이 간격 itemSpacing 과 트랙 안쪽 여백은 그대로입니다. 부모의 폭이 정해져 있어야 합니다(가로로 무한한 자리에서는 쓰지 않습니다). Figma 의 ToggleGroup 은 Width=Hug | Fill 축으로 같은 표현을 갖고, Fill 변형의 인스턴스는 가로 크기를 FILL 로 둡니다.

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

Do#

  • 옵션이 2~4개이고 이름이 짧을 때 쓰세요.
  • 항상 하나가 선택된 상태에서 시작하세요 (value 가 null 이면 아무것도 강조되지 않습니다).

Don't#

  • 긴 라벨이나 다섯 개 이상의 옵션에는 쓰지 마세요. 한 줄에 다 들어가지 않으면 Select 가 맞습니다.
  • 콘텐츠 패널을 바꾸는 탭으로 쓰지 마세요. 그때는 Tabs 입니다.

접근성 (Accessibility)#

역할#

  • Web: 트랙이 role="radiogroup", 각 칸이 role="radio" + aria-checked + aria-label=item.label 입니다. 비활성이면 그룹에 aria-disabled="true" 가 붙고 칸은 탭 정지점이 없습니다.
  • Flutter: 각 칸이 Semantics(checked:, inMutuallyExclusiveGroup: true, enabled:, label:) 입니다. 합성한 Clickable 은 버튼 역할을 내려놓습니다(semanticsButton: false).

키보드#

키FlutterWeb
Tab칸마다 정지그룹당 하나(선택한 칸, 없으면 첫 칸)
Enter / Space포커스된 칸 선택포커스된 칸 선택
← → ↑ ↓ 프레임워크 방향성 포커스 이동 (선택은 바꾸지 않음) 이전/다음 칸으로 이동하며 선택, 끝에서 순환

Web 은 WAI-ARIA radio group 패턴(roving tabindex)을 따릅니다.

스크린 리더#

각 칸의 이름은 item.label 입니다. showLabel: false 인 아이콘 칸도 이름이 남습니다. 그룹 자체의 이름은 없습니다 — 호출부가 곁에 보이는 제목을 두세요.

포커스 관리#

오버레이가 아니므로 포커스 이동·트랩이 없습니다. 포커스 링은 합성한 Clickable 의 기본 링이 칸마다 그립니다.

알려진 제약#

  • Flutter 는 칸마다 탭 정지점이 있고 화살표가 선택을 바꾸지 않습니다(Web 만 radio group 키 패턴).
  • 개별 칸 비활성화는 없습니다.

이 컴포넌트가 직접 다루지 않는 대비·모션 등 전역 축은 전역 접근성 축 에서 한 곳으로 관리됩니다.

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

items / value / onChanged / enabled / size / direction / fillWidth / toggleGroupStyle 은 양 플랫폼 동일 API 입니다.

항목FlutterWeb
트랙 합성한 OutlinedContainer <div role="radiogroup"> + 콘텐츠 표면 표시
선택 의미 Semantics(checked, inMutuallyExclusiveGroup) role="radio" · aria-checked
키보드칸별 Tab · Enter / Spaceroving tabindex · 양 축 화살표로 선택 이동
hover 상태 레이어 Clickable.onHover 가 잡고 칸의 foregroundDecoration 으로 깝니다 칸의 마커 클래스 + 인라인 --coui-state-layer-hover , 전역 규칙이 :hover 에서 background-image 로 깝니다
선택 면 그림자 BoxShadow (CoreShadow.xs) box-shadow: var(--shadow-xs)
전환AnimatedContainerCSS transition
폭 유지숨긴 세미볼드 텍스트가 폭만 차지aria-hidden 라벨 고스트
  • Filter: 같은 모양에 초기화 버튼이 붙은 필터
  • Tabs: 콘텐츠 패널 전환
  • RadioGroup: 폼 안의 라디오 선택