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

RadioGroup

여러 항목 중 하나를 선택하는 라디오 버튼 그룹 컴포넌트

목차

여러 항목 중 하나만 선택할 수 있는 라디오 버튼 그룹 컴포넌트입니다.

기본 사용법 (Basic Usage)#

// options 기반 간편 사용
RadioGroup(
  value: selectedGender,
  onChanged: handleGenderChanged,
  options: [
    (value: 'male', label: '남성'),
    (value: 'female', label: '여성'),
    (value: 'other', label: '기타'),
  ],
)

// 수평 배치
RadioGroup(
  value: selectedSize,
  onChanged: handleSizeChanged,
  orientation: CoreRadioGroupOrientation.horizontal,
  options: [
    (value: 'sm', label: 'S'),
    (value: 'md', label: 'M'),
    (value: 'lg', label: 'L'),
    (value: 'xl', label: 'XL'),
  ],
)

// child 기반 커스텀 레이아웃
RadioGroup(
  value: selectedOption,
  onChanged: handleOptionChanged,
  child: Column(
    children: [
      Radio(value: 'option1', label: 'Option 1'),
      Radio(value: 'option2', label: 'Option 2'),
    ],
  ),
)
// options 기반 간편 사용
RadioGroup(
  value: selectedGender,
  onChanged: handleGenderChanged,
  options: [
    (value: 'male', label: '남성'),
    (value: 'female', label: '여성'),
    (value: 'other', label: '기타'),
  ],
)

// 수평 레이아웃
RadioGroup(
  value: selectedSize,
  onChanged: handleSizeChanged,
  orientation: CoreRadioGroupOrientation.horizontal,
  options: [
    (value: 'sm', label: 'S'),
    (value: 'md', label: 'M'),
    (value: 'lg', label: 'L'),
  ],
)

// child 기반 커스텀 레이아웃
RadioGroup(
  value: selectedOption,
  onChanged: handleOptionChanged,
  child: div([
    Radio(value: 'option1', label: 'Option 1'),
    Radio(value: 'option2', label: 'Option 2'),
  ]),
)

Live Preview#

사용 시기 (When to Use)#

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

  • 상호 배타적인 선택지 중 하나를 선택해야 하는 경우 (예: 성별, 결제 방법)
  • 선택지가 2~6개 이하로 모든 옵션을 동시에 표시해야 하는 경우
  • 사용자가 선택지를 비교하며 결정해야 하는 경우
  • 선택지마다 설명을 보고 골라야 하는 경우 — variant: card 로 설명까지 한 타일에 담습니다 (예: 요금제, 배송 속도)

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

  • Select: 선택지가 7개 이상이어서 드롭다운으로 공간을 절약해야 하는 경우
  • Checkbox: 여러 항목을 동시에 선택할 수 있는 경우
  • SwitchField: 켜기/끄기의 이진 선택인 경우

Props / Parameters#

T는 선택 값 타입, W는 Flutter의 Widget 또는 Web의 Component 합성 콘텐츠 타입입니다. 현재 Web RadioGroup의 값 선언은 String으로 제한되어 Flutter의 T와 타입 대칭 갭이 있습니다. 양쪽 호출을 공유해야 하는 소비자는 문자열 식별자로 매핑하며 이를 일반적인 플랫폼별 값 정책으로 의존하지 않습니다.

속성타입기본값설명
valueT?null현재 선택된 값
onChanged ValueChanged<T?>? null 선택 변경 콜백
options List<({T value, String label})>? null 간편 옵션 목록
childW?null커스텀 레이아웃
variant CoreRadioGroupVariant defaultVariant 시맨틱 변형 (위젯 파라미터). card 는 선택지마다 테두리 있는 타일을 그립니다 (아래 「카드 (선택 타일)」)
size CoreComponentSize md 크기 토큰 (위젯 파라미터)
orientation CoreRadioGroupOrientation vertical 배치 방향 (위젯 파라미터)
radioGroupStyle CoreRadioGroupStyle? null indicator chrome / nested slot 묶음
enabled bool? null (onChanged != null 로 유도) 활성화 여부
nameString?null폼 필드 이름
initialValue T? null uncontrolled 초기 선택값 — 넘기면 그룹이 자기 선택을 직접 들고 움직이고 value 는 읽지 않습니다 (아래 참고)
controller RadioGroupController<T?>? null (Flutter 전용 런타임 인프라) 외부 선택 상태 컨트롤러
focusNode FocusNode? null (Flutter 전용 런타임 인프라) 포커스 노드
statesController WidgetStatesController? null (Flutter 전용 런타임 인프라) 위젯 상태 컨트롤러

Radio 아이템 파라미터#

속성타입기본값설명
valueT— (required)이 아이템의 값
labelString?null레이블 텍스트
labelMaxLines int? null 라벨이 넘칠 때까지 허용하는 줄 수. null 은 현행대로 줄 수를 제한하지 않습니다
labelOverflow CoreTextOverflow? null labelMaxLines 에 도달한 라벨의 넘침 처리 ( clip / fade / ellipsis / visible )
description String? null 레이블 자리(레이블 또는 suffix ) 아래 한 단 작은 크기·더 옅은 전경으로 놓이는 보조 문장. 레이블 자리와 한 열로 쌓이며(간격 2, card 는 4), 있으면 인디케이터가 첫 줄 위쪽에 맞춰집니다. 기본 변형에서도 쓸 수 있습니다
prefixW?null인디케이터 앞 슬롯
suffix W? null 레이블 대신 넣는 커스텀 슬롯
enabled bool true 개별 아이템 활성화 여부
groupValue T? null 이 아이템이 속한 묶음의 선택값 — 같으면 선택됨. 그룹 안에서는 비우면 그룹의 선택값
onChanged ValueChanged<T?>? null 이 아이템을 고르면 value 로 호출. 그룹 안에서는 비우면 그룹의 콜백. 어느 쪽에도 없으면 반응하지 않습니다
groupVariant CoreRadioGroupVariant defaultVariant 그룹 밖에서 쓸 때의 변형 — 그룹 안에서는 그룹의 변형을 따르고 읽지 않습니다
groupSize CoreComponentSize md 그룹 밖에서 쓸 때의 크기 — 그룹 안에서는 그룹의 크기를 따르고 읽지 않습니다
focusNode FocusNode? null (Flutter 전용 런타임 인프라) 포커스 노드

Radio 는 두 플랫폼 모두 그룹 밖에서 단독으로 쓸 수 있습니다 — 위의 groupValue / onChanged / groupVariant / groupSize 를 직접 넘깁니다(아래 "그룹 밖에서 쓰기"). Web 은 여기에 폼 제출 이름 name 과 미리 풀어 둔 그룹 번들 group 을 더 받습니다. RadioGroup 안에서는 options 로 만든 아이템이든 child 로 넣은 Radio 든 그룹이 선택값 · 콜백 · 활성 · 변형 · 크기 · 폼 이름 · radioGroupStyle 을 내려주므로 직접 넘길 필요가 없습니다. 아이템에 직접 넘긴 값이 이기되, 변형과 크기는 언제나 그룹의 것을 따르고, 그룹이 비활성이면 아이템도 비활성입니다.

빠른 오버라이드 (Chain)#

이미 만든 RadioGroup 인스턴스에 스타일을 빠르게 덧붙이고 싶다면 withStyle 체인을 쓸 수 있습니다. 생성자의 style 슬롯 인자와 동일하게 동작하지만, 이미 구성된 위젯 위에서 바로 이어 쓸 수 있습니다.

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

  @override
  State<RadioGroupChainExample> createState() => _RadioGroupChainExampleState();
}

class _RadioGroupChainExampleState extends State<RadioGroupChainExample> {
  String? _selected = 'option1';

  @override
  Widget build(BuildContext context) {
    return RadioGroup<String>(
      value: _selected,
      onChanged: (v) => setState(() => _selected = v),
      options: const [
        (value: 'option1', label: 'Option 1'),
        (value: 'option2', label: 'Option 2'),
        (value: 'option3', label: 'Option 3'),
      ],
    ).withStyle(
      const CoreRadioGroupStyle(
        indicatorActiveColor: CoreColor.token(CoreColors.tertiary),
        indicatorBorderColor: CoreColor.token(CoreColors.tertiary),
        indicatorBorderWidth: CoreStrokeWidth.stroke3,
        selectedIndicatorBorderWidth: CoreStrokeWidth.stroke6,
        indicatorSize: CoreSize.size24,
        itemSpacing: CoreSpace.space16,
      ),
    );
  }
}
class RadioGroupChainExample extends StatefulComponent {
  const RadioGroupChainExample({super.key});

  @override
  State<RadioGroupChainExample> createState() => _RadioGroupChainExampleState();
}

class _RadioGroupChainExampleState extends State<RadioGroupChainExample> {
  String? _selected = 'option1';

  @override
  Component build(BuildContext context) {
    return RadioGroup(
      value: _selected,
      onChanged: (v) => setState(() => _selected = v),
      options: const [
        (value: 'option1', label: 'Option 1'),
        (value: 'option2', label: 'Option 2'),
        (value: 'option3', label: 'Option 3'),
      ],
    ).withStyle(
      const CoreRadioGroupStyle(
        indicatorActiveColor: CoreColor.token(CoreColors.tertiary),
        indicatorBorderColor: CoreColor.token(CoreColors.tertiary),
        indicatorBorderWidth: CoreStrokeWidth.stroke3,
        selectedIndicatorBorderWidth: CoreStrokeWidth.stroke6,
        indicatorSize: CoreSize.size24,
        itemSpacing: CoreSpace.space16,
      ),
    );
  }
}

스타일 시스템 (Style System)#

radioGroupStyle 슬롯#

RadioGroup 의 indicator chrome / 슬롯 미세 조정은 단일 radioGroupStyle (CoreRadioGroupStyle) 으로 흐릅니다. 시맨틱 enum (variant, size, orientation) 은 위젯 파라미터.

RadioGroup<String>(
  variant: CoreRadioGroupVariant.defaultVariant,
  size: CoreComponentSize.md,
  orientation: CoreRadioGroupOrientation.vertical,
  value: selected,
  options: [
    (value: 'apple', label: '사과'),
    (value: 'banana', label: '바나나'),
  ],
  onChanged: handleChange,
  radioGroupStyle: CoreRadioGroupStyle(
    indicatorBorderColor: CoreColor.token(CoreColors.outline),
    indicatorActiveColor: CoreColor.token(CoreColors.primary),
    indicatorLabelGapStyle: CoreGapStyle(size: CoreSpace.space8),
    itemSpacing: CoreSpace.space12,
    indicatorDotStyle: CoreIconStyle(
      size: CoreSize.size8,
      color: CoreColor.token(CoreColors.onPrimary),
    ),
    labelStyle: CoreTextStyle(fontWeight: CoreFontWeight.semiBold),
  ),
)

CoreRadioGroupStyle 필드#

필드타입설명
indicatorBackgroundColor CoreColor? Indicator background fill colour override (idle / unselected).
indicatorActiveColor CoreColor? Indicator background fill colour when selected.
indicatorBorderColor CoreColor? Indicator border stroke colour override.
indicatorBorderWidth double? Indicator border stroke width override (logical px, pre-scaling) for the resting ring — unselected, and disabled-unselected. The selected ring (disabled or not) reads [selectedIndicatorBorderWidth], so restating only this field changes the resting ring and leaves the selected one on its size row.
selectedIndicatorBorderWidth double? Selected ring stroke width override (logical px, pre-scaling). The selection is carried by this ring — thicker than the resting one, with the hole left open — not by a dot. A disabled selected ring keeps this width (in the disabled-content colour). null defers to the size-keyed [defaultsBySize] entry, and a size whose entry states none draws the selected ring at the resting [indicatorBorderWidth]. The ring is drawn inside the indicator box, so the hole's diameter is indicatorSize − 2 × selectedIndicatorBorderWidth . A ring with a dot in its hole is stated with innerDotSize and this field set to the resting width.
indicatorSize double? Indicator size (width = height) override (logical px).
indicatorBorderRadius CoreBorderRadius? Selection ring corner radius override. Falls back to [defaultIndicatorBorderRadius] (a full circle) when null.
innerDotSize double? Inner dot size override (logical px) — applied when the item is selected. Falls back to [defaultsBySize] when null, which states 0 : no dot is drawn by default, because the selected ring carries the selection ([selectedIndicatorBorderWidth]).
indicatorLabelGapStyle CoreGapStyle? Nested [CoreGapStyle] slot for the gap between the indicator and the item label / prefix / suffix slot — forwarded straight to Gap(gapStyle: …) . Gap runs its own resolve step so the slot stays in pre-scaling units. null defers to the size-keyed [defaultsBySize] entry.
itemSpacing double? Inter-item spacing (logical px, pre-scaling) between sibling radio items. Flows directly into Wrap.spacing / Wrap.runSpacing (horizontal orientation) or Column.spacing (vertical orientation) — both are native paint-API arguments that consume raw doubles, so this slot stays scalar ( double? ) rather than the nested [CoreGapStyle] shape used for Gap slots. Falls back to the size-keyed [defaultsBySize] entry when null.
colorTransitionDuration Duration? Indicator border / background colour transition duration override. Falls back to [defaultColorTransitionDuration] when null.
dotTransitionDuration Duration? Inner dot scale transition duration override. Falls back to [defaultDotTransitionDuration] when null.
indicatorDotStyle CoreIconStyle? Inner dot style (size / colour) when the item is selected. The dot is rendered as an icon, so this nests CoreIconStyle . null defers to [defaultIndicatorDotStyle], whose unset size / colour then fall through to [defaultsBySize] and the resolved active fill.
labelStyle CoreTextStyle? Item label text style override. Text colour + typography role are both carried inside this slot; the per-size fontSize step comes from [defaultsBySize] underneath it.
descriptionStyle CoreTextStyle? Description text style override — the supporting line under the label. Merges over the size row's role ( defaultsBySize ) and [defaultDescriptionStyle]'s foreground, so a caller that states only a weight keeps both. Disabled reads the variant's disabled foreground.
labelDescriptionGapStyle CoreGapStyle? Nested [CoreGapStyle] slot for the vertical gap between the label and the description below it. null defers to the size layer (the card rows state one), then to [defaultLabelDescriptionGapStyle].
stateLayer CoreStateLayer? Ramp laid around the indicator while the row is hovered or pressed. Defaults to [defaultStateLayer]; the focus weight is not painted (see the default's doc).
stateLayerColor CoreColor? Ink of the halo. Defaults to [defaultStateLayerColor].
stateLayerSpread double? How far the halo reaches past the indicator on each side (logical px, pre-scaling). Defaults to [defaultStateLayerSpread]. The card variant draws no halo — its state layer covers the whole tile, clipped to the tile's corner — so this reaches nothing there.
tilePadding CoreEdgeInsets? Inset between the tile's border and its content (logical px, pre-scaling). null defers to [cardDefaultsBySize]. Read only by the card variant.
tileSelectedPadding CoreEdgeInsets? Padding inside the selected card's border, before scaling. When null, the extra selected stroke is deducted from [tilePadding], preserving the resting card's content position and outer size. An explicit value opts into independent selected-state layout, including disabled selected cards. Ignored by the plain variant. There is deliberately no default: a fixed value would disable the border-width compensation and alter the existing selected card size and position.
tileBorderRadius CoreBorderRadius? Tile corner. null defers to [cardDefaultsBySize]. The focus ring around a focused tile follows this corner. Read only by the card variant.
tileBorderWidth double? Stroke width of an unselected tile (logical px, pre-scaling). null defers to [defaultTileBorderWidth]. It is also the width the tile's content is inset by in both states unless [tileSelectedPadding] is set. Read only by the card variant.
tileSelectedBorderWidth double? Stroke width of the selected tile (logical px, pre-scaling), disabled or not. null defers to [defaultTileSelectedBorderWidth]. The extra over [tileBorderWidth] is drawn inward and moves neither the content nor the tile's box unless [tileSelectedPadding] is set (see the default's doc). Read only by the card variant.
tileBackgroundColor CoreColor? Face of an unselected tile, disabled or not. null defers to [defaultTileBackgroundColor].
tileSelectedBackgroundColor CoreColor? Face of the selected tile, disabled or not. null defers to [defaultTileSelectedBackgroundColor].
tileBorderColor CoreColor? Border of an unselected tile. null defers to [defaultTileBorderColor]. A disabled tile reads the variant's disabledBorderColor instead, selected or not.
tileSelectedBorderColor CoreColor? Border of the selected tile. null defers to [defaultTileSelectedBorderColor]. A disabled selected tile reads the variant's disabledBorderColor instead.

CoreRadioGroupStyle 변형별 기본값 (CoreRadioGroupVariantStyle)#

필드defaultVariantcard
activeColorprimaryprimary
borderColoroutlineoutline
uncheckedBackgroundtransparenttransparent
disabledActiveColordisabledContentdisabledContent
disabledBorderColordisabledOutlinedisabledOutline
disabledBackgroundColordisabledContainerdisabledContainer
disabledForegroundColordisabledContentdisabledContent

Resolve chain#

design system default for variant / size
  → CoreRadioGroupTheme.style                       // 프로젝트 공통
  → CoreRadioGroupTheme.variantStyles[variant]      // variant 별
  → parent component slot override
  → widget.radioGroupStyle                          // 인스턴스별

맨 아래 크기 층은 CoreRadioGroupStyle.sizeDefaultsFor(variant, size) 한 곳이 정합니다 — 기본 변형은 defaultsBySize[size], card 는 그 위에 cardDefaultsBySize[size] 를 덮은 행입니다. 두 플랫폼이 같은 함수를 읽어 층의 순서가 갈리지 않습니다.

각 nested 슬롯 스타일 (indicatorDotStyle / labelStyle / descriptionStyle / labelDescriptionGapStyle) 은 자기 컴포넌트의 resolve chain 으로 다시 한 번 머지됩니다.

크기 사다리 (defaultsBySize)#

인디케이터 · 쉬는 링 두께 · 켜진 링 두께 · dot · 인디케이터↔레이블 gap · 레이블 fontSize · 설명 역할이 한 행에 묶여 있습니다. 선택은 점이 아니라 링이 나릅니다 — 켜진 링은 쉬는 링보다 두껍고 가운데는 비어 있으며(구멍 지름 = 인디케이터 − 2 × 켜진 링 두께), dot 은 기본에서 그려지지 않습니다(innerDotSize 0). 레이블은 Button 의 사다리와 같은 크기이며 역할은 bodySmall(400 / 150%) 입니다. 설명(description)은 레이블보다 한 단 작은 크기를 역할로 적습니다 — 본문 스케일에 11 이 없어 xs 는 sm 과 같은 bodyXs 12 입니다.

Size 인디케이터 쉬는 링 켜진 링 구멍 dot gap 레이블 fontSize 설명 역할
xs 12 1.5 3 6 0 6 12 bodyXs 12
sm 14 1.5 4 6 0 8 13 bodyXs 12
md 16 1.5 4 8 0 8 14 bodySmall 13
lg 18 1.5 5 8 0 8 16 bodyMedium 14
xl 20 2 5 10 0 10 16 bodyMedium 14
xl2 24 2 6 12 0 12 16 bodyMedium 14

card 변형은 이 행 위에 타일 행(cardDefaultsBySize)을 덮습니다 — 아래 「카드 (선택 타일)」.

쉬는 링 1.5 는 활성 경계(stroke1_5)입니다. 비활성 링도 선택 여부를 따릅니다 — 비활성 + 미선택은 쉬는 두께이고 비활성 + 선택은 켜진 두께입니다. 켜진 링 두께는 selectedIndicatorBorderWidth 가 정하며 indicatorBorderWidth 와 서로 독립입니다 — 한쪽만 덮으면 다른 쪽은 크기 행 값 그대로입니다. 옛 모양(링 + 점)은 innerDotSize 와 selectedIndicatorBorderWidth 를 직접 적어 되돌립니다.

변형 (Variants)#

수직 배치 (기본)#

RadioGroup(
  value: selectedPlan,
  onChanged: handlePlanChanged,
  options: [
    (value: 'basic', label: '베이직'),
    (value: 'pro', label: '프로'),
    (value: 'enterprise', label: '엔터프라이즈'),
  ],
)

수평 배치#

RadioGroup(
  value: selectedPayment,
  onChanged: handlePaymentChanged,
  orientation: CoreRadioGroupOrientation.horizontal,
  options: [
    (value: 'card', label: '카드'),
    (value: 'transfer', label: '계좌이체'),
    (value: 'phone', label: '휴대폰'),
  ],
)

카드 (선택 타일)#

variant: CoreRadioGroupVariant.card 는 선택지마다 인디케이터 · 제목 · (선택) 설명을 테두리 있는 타일 하나에 담습니다. 요금제나 배송 속도처럼 설명을 보고 고르는 선택지에 씁니다 — 설명이 고르는 대상 바깥에 놓이지 않습니다.

  • 타일 전체가 누르는 영역입니다. hover · pressed 상태층은 인디케이터 둘레가 아니라 타일 전체를 덮고, 키보드 포커스 링도 타일의 모서리를 따라 타일을 두릅니다.
  • 선택은 여전히 인디케이터의 링이 나르고, 타일은 면과 테두리로 한 번 더 말합니다 — 꺼진 타일은 surface 면에 outlineVariant 테두리 1, 켜진 타일은 primaryContainer 면에 링과 같은 primary 테두리 2 입니다.
  • 켜진 타일의 더 굵은 테두리는 안쪽으로 그려져 선택해도 내용과 타일 크기가 그대로입니다 — 내용은 두 상태 모두 패딩 + 쉬는 테두리(1) 만큼 들어와 있습니다. Flutter 는 실제 테두리를 그리고 차이만큼 패딩을 덜어 내고(테두리 전환과 같은 박자로), Web 은 흐름 속 테두리를 쉬는 두께로 두고 차이를 테두리 색의 inset box-shadow 로 칠합니다. 켜진 두께를 쉬는 두께보다 얇게 주면 쉬는 두께로 그립니다.
  • 꺼진 타일의 테두리는 컨트롤의 경계가 아닙니다 — 그 대비는 안쪽 인디케이터의 링이 맡으므로 테두리는 옅은 선으로 둡니다.
  • 수직 배치에서는 타일이 그룹 너비로 늘어나고, 수평 배치에서는 각자 내용 너비입니다.
  • 크기는 네 행입니다 — xs · sm · md 는 촘촘한 타일(패딩 10 · 12, 모서리 10, 제목 label-large 14 / 500, 설명 body-xs 12, 제목↔설명 4), lg 는 넉넉한 타일(패딩 12 · 14, 모서리 12, 제목 label-xl 15 / 500, 설명 body-small 13, 제목↔설명 4), xl 은 패딩 16 · 모서리 16 · 제목 title-small 16 / 600 · 설명 body-small 13 · 제목↔설명 2, xl2 는 패딩 20 · 모서리 20 · 제목 title-medium 18 / 600 · 설명 body-medium 14 · 제목↔설명 2 입니다. 인디케이터는 모든 행에서 18 이고, 큰 크기가 작은 크기보다 패딩 · 모서리 · 글자가 작아지는 일은 없습니다.
  • 비활성 타일은 면(쉬는 / 켜진)과 테두리 두께를 그대로 두고, 테두리를 disabledOutline 으로, 글자를 비활성 글자색으로 바꿉니다.
  • 타일 값은 radioGroupStyle 의 tilePadding · tileSelectedPadding · tileBorderRadius · tileBorderWidth · tileSelectedBorderWidth · tileBackgroundColor · tileSelectedBackgroundColor · tileBorderColor · tileSelectedBorderColor 로 덮습니다.
  • description 은 options 레코드에 없습니다 — 설명이 붙은 타일은 child 에 Radio 를 넣어 만듭니다. 그 Radio 는 두 플랫폼 모두 그룹의 선택값 · 변형 · 크기 · radioGroupStyle(타일 값 포함)을 물려받습니다.
RadioGroup<String>(
  value: selectedPlan,
  onChanged: handlePlanChanged,
  variant: CoreRadioGroupVariant.card,
  child: Column(
    crossAxisAlignment: CrossAxisAlignment.stretch,
    spacing: CoreSpace.space8,
    children: const [
      Radio(value: 'basic', label: '베이직', description: '개인 프로젝트용'),
      Radio(value: 'pro', label: '프로', description: '팀 협업과 우선 지원'),
    ],
  ),
)
RadioGroup(
  value: selectedPlan,
  onChanged: handlePlanChanged,
  variant: CoreRadioGroupVariant.card,
  child: div(
    [
      Radio(value: 'basic', label: '베이직', description: '개인 프로젝트용'),
      Radio(value: 'pro', label: '프로', description: '팀 협업과 우선 지원'),
    ],
    classes: 'flex flex-col gap-${CoreSpace.scale.space8}',
  ),
)

그룹 밖에서 쓰기#

선택지 하나하나를 따로 배치해야 할 때 — 카드형 선택지를 화면 곳곳에 두거나 목록 행 안에 라디오를 넣을 때 — Radio 를 그룹 없이 쓰고 선택값과 콜백을 직접 넘깁니다. 같은 groupValue 를 공유하는 라디오들이 한 묶음처럼 동작합니다.

// Flutter
Column(
  children: [
    Radio<String>(
      value: 'pickup',
      groupValue: selected,
      groupVariant: .card,
      label: '직접 받기',
      onChanged: (v) => setState(() => selected = v),
    ),
    Radio<String>(
      value: 'delivery',
      groupValue: selected,
      groupVariant: .card,
      label: '배달',
      onChanged: (v) => setState(() => selected = v),
    ),
  ],
)
// Web (Jaspr)
div([
  Radio(
    value: 'pickup',
    groupValue: selected,
    groupVariant: .card,
    label: '직접 받기',
    onChanged: (v) => setState(() => selected = v),
  ),
  Radio(
    value: 'delivery',
    groupValue: selected,
    groupVariant: .card,
    label: '배달',
    onChanged: (v) => setState(() => selected = v),
  ),
])

그룹 밖의 라디오에는 그룹이 주는 화살표 이동(roving)이 없습니다. 그래서 선택 여부와 무관하게 라디오마다 Tab 정지점이 있습니다 — 그룹 안처럼 선택된 것만 남기면 선택되지 않은 라디오에 키보드로 닿을 길이 없어지기 때문입니다.

동작 스펙 (Behavior)#

인터랙션#

  • 클릭/탭: 라디오 버튼 또는 레이블 클릭 시 해당 옵션 선택 — 행 전체(인디케이터 + 레이블)가 히트 영역
  • 호버 / 눌림: 행 위에 포인터가 머물면 인디케이터 바깥 stateLayerSpread(기본 4px) 만큼 나간 원형 halo 가 stateLayerColor(기본 onSurface) 를 stateLayer.hover(8%) 로, 누르는 동안 stateLayer.pressed(16%) 로 깔립니다. 박스 안쪽이 아니라 바깥에 그리는 이유는 12–24px 박스 안의 8% 워시가 보이지 않기 때문입니다. Flutter 는 Clickable 의 hover/press DETECTION, Web 은 root 의 :hover / :active scoped rule 이 같은 CoreStateLayer 가중치를 읽습니다.
  • 포커스: 키보드 포커스 시 인디케이터 둘레에 FocusOutline 링(focus-ring, 2px) — 인디케이터와 링 사이 2px 간격은 투명 — 포커스 상태에는 halo 가 깔리지 않습니다.
  • 카드 (card): 타일 전체가 히트 영역입니다. 상태층은 halo 대신 타일 면 전체에 타일 모서리로 잘려 깔리고, 포커스 링은 인디케이터가 아니라 타일을 두릅니다.
  • 비활성: 박스 fill disabledContainer / 레이블 · 설명(과 되돌린 dot) disabledContent. 링은 선택 여부를 따릅니다 — 비활성 + 미선택은 disabledOutline 의 쉬는 두께, 비활성 + 선택은 disabledContent 의 켜진 두께입니다. dot 이 기본에서 그려지지 않으므로 비활성에서 선택을 표시하는 것은 링의 색과 두께 둘뿐이고, 두 값이 모두 비활성 + 미선택 링과 다르게 정해져 있어 켜짐과 꺼짐이 같은 모양이 되지 않습니다(다시 dot 을 켠 경우의 dot 색도 disabledContent).

상태 전환#

  • unselected -> selected (클릭 또는 Space/Enter 입력 시)
  • 이미 선택된 항목은 클릭해도 unselected로 변경되지 않음 (단일 선택 보장)
  • disabled 상태의 개별 항목은 선택 불가

그룹 동작#

  • 동일 그룹 내에서는 하나의 항목만 선택 가능
  • 선택 변경 시 이전 선택 항목은 자동으로 해제

선택을 누가 들고 있는가 — value vs initialValue#

두 모드는 배타적입니다. 한 선택에 두 출처를 두면 어느 쪽이 이기는지가 정의되지 않기 때문에, initialValue 를 넘긴 그룹은 value 를 읽지 않습니다.

모드넘기는 것선택을 들고 있는 곳
controlled value (+ onChanged) 호출자. onChanged 를 받아 value 를 다시 내려줘야 화면이 바뀝니다
uncontrolled initialValue (+ 선택적 onChanged) 그룹 자신. 여기서 시작해 스스로 움직이고, onChanged 는 변경 보고용

양 플랫폼 동일하게 동작합니다.

// controlled — 호출자가 value 를 다시 내려준다
RadioGroup(
  value: selected,
  onChanged: (v) => setState(() => selected = v),
  options: options,
)

// uncontrolled — 그룹이 자기 선택을 들고 있다
RadioGroup(
  initialValue: 'option1',
  onChanged: (v) => analytics.log(v),
  options: options,
)

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

보이는 옵션 중 하나의 값을 고르려면 RadioGroup을 사용합니다. 목록을 드롭다운에 담아야 하면 Select, 각 옵션을 독립적으로 켜고 끄는 다중 선택은 Checkbox, 옵션을 칩 형태로 표시하려면 ChipGroup을 선택합니다. 자식 패널 전환은 Tabs의 역할입니다.

선택 값 없이 공간적 포커스 이동만 필요하면 Subfocus, 테마 팔레트 스와치로 단일 선택을 보여주려면 ThemeController를 사용합니다.

Do#

선택지를 모두 명확하게 표시

RadioGroup(
  value: selectedDelivery,
  onChanged: handleDeliveryChanged,
  options: [
    (value: 'standard', label: '일반 배송 (3-5일)'),
    (value: 'express', label: '빠른 배송 (1-2일)'),
    (value: 'same_day', label: '당일 배송'),
  ],
)

모든 옵션을 한 번에 볼 수 있어 사용자가 신중하게 비교하고 선택할 수 있다.


Don't#

선택지가 많을 때 RadioGroup 사용

// 10개 이상의 옵션은 화면을 과하게 차지함
RadioGroup(
  value: selectedCountry,
  onChanged: handleCountryChanged,
  options: allCountries.map((c) => (value: c.code, label: c.name)).toList(),
  // 200여 개 국가 - Select를 사용해야 함
)

선택지가 많으면 스크롤이 필요해 비교가 어렵고 화면을 과도하게 차지한다.

접근성 (Accessibility)#

역할#

  • Flutter — 그룹은 Semantics(container: true) 로 이름 없는 컨테이너 경계 하나를 만듭니다. 다만 controller 나 initialValue 를 준 경로에서는 그 경계조차 없습니다 — 그 경우 컨트롤러 어댑터를 그대로 반환해 Semantics 를 지나지 않습니다. 항목 Radio 는 Semantics(checked:, inMutuallyExclusiveGroup: true) 로 선택 상태와 상호 배타 묶음을 내보내고, 행의 라벨 텍스트가 그 노드의 이름이 됩니다 — 그룹 안이든 밖이든 같습니다.
  • Web — 그룹 루트에 role="radiogroup"(비활성이면 aria-disabled="true"), 각 항목의 원 요소에 role="radio" · aria-checked · roving tabindex 가 붙고, 비활성 항목에는 aria-disabled="true" 가 더해집니다.

그룹 이름은 Web 에서만 RadioGroup(attributes: {'aria-label': ...}) 로 루트에 실을 수 있습니다 — Flutter 에는 대응 슬롯이 없습니다.

name 을 주면 폼 제출용 히든 <input type="radio"> 가 함께 나가지만 sr-only · tabindex="-1" · aria-hidden="true" 라 접근성 트리에는 들어가지 않습니다.

키보드#

키FlutterWeb
Tab 선택된 항목 하나만 tab stop (선택이 없으면 전부) 선택된 항목 하나만 tab stop (선택이 없으면 전부)
ArrowDown / ArrowRight 다음 항목으로 포커스 이동 (끝에서 순환) 다음 항목으로 포커스 이동 (끝에서 순환)
ArrowUp / ArrowLeft 이전 항목으로 포커스 이동 (끝에서 순환) 이전 항목으로 포커스 이동 (끝에서 순환)
Space / Enter없음포커스된 항목 선택
Home / End 없음 없음

키보드 포커스가 항목에 닿으면 그 항목이 곧바로 선택됩니다(selection follows focus) — 네이티브 라디오 그룹 관례입니다. Flutter 는 onShowFocusHighlight, Web 은 :focus-visible 로 같은 규칙을 씁니다. Flutter 에 별도 활성화 키 핸들러가 없어도 화살표 이동만으로 선택이 따라오는 것은 이 때문입니다.

스크린 리더#

Web 은 각 항목에서 라디오 역할과 선택 상태를 읽지만, label 로 넣은 텍스트가 그 항목의 이름이 되지는 않습니다(아래 알려진 제약 참고). Flutter 는 각 항목을 선택 상태(checked)가 있는 상호 배타 항목으로 읽고, 라벨 텍스트를 그 항목의 이름으로 읽습니다.

description 도 레이블처럼 그려진 텍스트로만 읽힙니다 — 양 플랫폼 모두 항목의 설명(Web aria-describedby)으로 잇지 않습니다.

그룹 이름은 Web 에서 attributes 로 넣지 않는 한 양쪽 다 비어 있습니다 — 어떤 질문에 답하는 그룹인지는 통보되지 않습니다. Flutter 에서는 호출부가 Semantics(label: ...) 나 Fieldset 으로 감싸야 합니다.

선택이 바뀐 사실을 따로 알리는 live region 은 없습니다.

포커스 관리#

  • 진입 — 오버레이가 아니므로 마운트 시 포커스를 옮기지 않습니다. Tab 으로 들어오면 선택된 항목 하나에 놓입니다(roving tab stop) — 아직 선택이 없으면 모든 항목이 tab stop 이라 하나씩 지나갑니다.
  • 이탈 — 해당 없음. Tab 은 그룹 밖으로 그대로 나갑니다.
  • 트랩 — 없음. 다만 화살표는 그룹 안에서 순환합니다 — 끝에서 반대편으로 감기고 그룹 밖으로 나가지 않습니다.
  • 포커스 표시 — 원형 FocusOutline 이 선택된 항목이 키보드 포커스일 때만 나타납니다(양 플랫폼 동일 게이트: _focused && isSelected).

알려진 제약#

  • Flutter 의 그룹 이름에는 슬롯이 없습니다. RadioGroup 이 붙이는 것은 이름 없는 컨테이너 경계 하나뿐이라, 어떤 질문에 답하는 묶음인지 알리려면 호출부가 Semantics(label: ...) 나 Fieldset 으로 감싸야 합니다.
  • Web 에서 label 을 준 라디오는 그 라벨이 role="radio" 요소의 이름이 되지 않습니다. 라벨 텍스트는 바깥 <label> 안에서 원 모양 요소의 형제로 놓이고 둘을 잇는 aria-labelledby / aria-label 은 어디에서도 나오지 않는데, <label> 은 네이티브 폼 컨트롤만 이름 지어 줍니다. Radio(attributes: {'aria-label': ...}) 도 label · prefix · suffix · name 중 하나라도 있으면 바깥 <label> 쪽으로 실려 원 요소에 닿지 않으므로, 시각 라벨을 유지한 채 이름을 붙일 방법이 없습니다.
  • 비활성 항목이 Flutter 에서는 여전히 포커스 대상입니다. Web 은 비활성 라디오에 tabindex="-1" 을 주고 화살표 순회 질의에서도 :not([aria-disabled="true"]) 로 걸러내지만, Flutter 는 항목의 포커스 노드를 활성 여부와 무관하게 그룹에 등록하고 포커스 감지기에도 enabled 를 넘기지 않아 Tab 과 화살표가 비활성 항목에 그대로 멈춥니다(선택은 바뀌지 않습니다). 같은 그룹을 두 플랫폼에서 키보드로 훑으면 멈추는 지점의 개수가 달라집니다.
  • 라벨 없는 xs 라디오를 세로로 나열하면 인접 항목의 포인터 영역이 겹칩니다. 지시자가 12px 이라 최소 24px 을 채우려고 위아래로 6px 씩 넓어지는데 기본 itemSpacing 이 6px 이라, 두 항목 사이 6px 구간을 양쪽이 함께 차지합니다. Flutter 는 그 구간의 포인터를 나중에 그려진 형제에게 줍니다(히트 테스트가 페인트 역순). 라벨을 단 항목은 확장이 라벨 쪽으로 흡수되어 주축에서는 이미 최소치를 넘으므로 해당하지 않습니다. radioGroupStyle: CoreRadioGroupStyle(itemSpacing: ...) 로 간격을 12px 이상 벌리거나 sm 이상 크기를 쓰면 겹치지 않습니다.

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

항목FlutterWeb
클래스명RadioGroup<T>RadioGroup
아이템Radio<T>Radio
제네릭T (any type)String only
childWidget? childComponent? child
상태 컨트롤러 controller / focusNode / statesController 브라우저 기본 포커스 + DOM
uncontrolled 배관 ControlledComponentAdapter State 안 내부 선택값
링 굵기 1.5 1.5 그대로 1배 화면은 브라우저가 1.5 CSS px 를 기기 픽셀로 내려 1px 로 그릴 수 있음 (2배 이상은 3 기기 픽셀)
label 과 suffix 를 함께 줄 때 suffix 가 레이블 자리를 대신함 — description 은 suffix 아래 레이블과 suffix 를 나란히 그림 — description 은 그 줄 아래

레거시 vs 통일 비교 (Migration Notes)#

이전 버전의 Web CoUI 를 참조하는 코드에는 지금의 RadioGroup 컨테이너 역할을 하던 최상위 클래스가 Radio 라는 이름이었을 수 있습니다. Flutter 쪽 이름(RadioGroup)에 맞춰 Web 도 RadioGroup 으로 개명되었고, 지금의 Radio(단수)는 그룹 안 개별 라디오 아이템을 가리키는 별도 컴포넌트입니다 — 같은 이름을 두 시점에서 다른 의미로 재사용한 것이므로 혼동하지 않도록 주의하세요.

항목레거시 (구 Web 최상위 Radio)통일 RadioGroup
의미라디오 그룹 컨테이너라디오 그룹 컨테이너 (동일 역할, 새 이름)
Flutter 대응항상 RadioGroupRadioGroup

마이그레이션: 구 Web 코드의 Radio(value:, onChanged:, options: [...]) 류 최상위 사용은 RadioGroup(value:, onChanged:, children: [Radio(...), ...]) 로 옮깁니다 — 오늘의 Radio 는 그 children 안에 들어가는 개별 아이템입니다.

  • Checkbox: 여러 항목을 동시에 선택할 수 있는 경우
  • Select: 선택지가 많아 드롭다운이 필요한 경우
  • SwitchField: 켜기/끄기 이진 상태를 표현하는 경우

조합 예제#

// RadioGroup + Fieldset 조합: 요금제 선택 폼
Fieldset(
  legend: '요금제',
  description: '월 단위로 청구됩니다',
  children: [
    RadioGroup(
      value: selectedPlan,
      onChanged: handlePlanChanged,
      options: [
        (value: 'free', label: '무료 (5GB)'),
        (value: 'pro', label: '프로 (100GB) - 9,900원/월'),
        (value: 'business', label: '비즈니스 (무제한) - 29,900원/월'),
      ],
    ),
  ],
)

선택 카드 패딩을 별도로 지정하기#

기본 카드 라디오는 선택 테두리가 두꺼워져도 높이와 콘텐츠 위치를 유지합니다. 시안이 선택 상태에서 다른 내부 패딩이나 외곽 크기를 요구할 때만 tileSelectedPadding을 지정합니다. 예를 들어 일반/선택 패딩을 모두 CoreEdgeInsets.symmetric(horizontal: 16, vertical: 14)로 하고 경계를 각각1/1.5로 지정하면 선택 카드가1px 더 높아집니다. 미지정은 기존 동작을 그대로 유지합니다. Flutter와 Web은 같은 슬롯을 사용하고 비활성 선택에도 같은 배치가 적용됩니다. 의미·선택·키보드 동작은 바뀌지 않습니다.