RadioGroup | CoUI
LogoCoUI

RadioGroup

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

RadioGroup#

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

Live Preview#

사용 시기 (When to Use)#

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

  • 상호 배타적인 선택지 중 하나를 선택해야 하는 경우 (예: 성별, 결제 방법)
  • 선택지가 2~6개 이하로 모든 옵션을 동시에 표시해야 하는 경우
  • 사용자가 선택지를 비교하며 결정해야 하는 경우

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

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

기본 사용법 (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', groupValue: selectedOption, label: 'Option 1', onChanged: handleOptionChanged),
    Radio(value: 'option2', groupValue: selectedOption, label: 'Option 2', onChanged: handleOptionChanged),
  ]),
)

Props / Parameters#

속성타입기본값설명
valueT?null현재 선택된 값
onChanged ValueChanged<T?>? null 선택 변경 콜백
options List<({T value, String label})>? null 간편 옵션 목록
child Widget? / Component? null 커스텀 레이아웃
variant CoreRadioGroupVariant defaultVariant 시맨틱 변형 (위젯 파라미터)
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레이블 텍스트
prefix Widget? / Component? null 인디케이터 앞 슬롯
suffix Widget? / Component? null 레이블 대신 넣는 커스텀 슬롯
enabled bool true 개별 아이템 활성화 여부
focusNode FocusNode? null (Flutter 전용 런타임 인프라) 포커스 노드

Web Radio 는 그룹 밖에서 단독으로도 쓸 수 있어 groupValue / onChanged / name / groupVariant / groupSize / group 을 추가로 받습니다. RadioGroup 안에 넣으면 그룹이 이 값들을 내려주므로 직접 넘길 필요가 없습니다.

빠른 오버라이드 (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,
        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,
        indicatorSize: CoreSize.size24,
        itemSpacing: CoreSpace.space16,
      ),
    );
  }
}

스타일 시스템 — 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).
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.
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 (sb8 — raw labelColor / labelTypography fields removed).

CoreRadioGroupStyle 변형별 기본값 (CoreRadioGroupVariantStyle)#

필드defaultVariant
activeColorprimary
borderColoroutline
uncheckedBackgroundsurfaceContainer (opacity 0.3)
disabledActiveColorsurfaceContainer
disabledBorderColorsurfaceContainer
disabledBackgroundColorsurfaceContainer
disabledForegroundColoronSurfaceVariant

Resolve chain#

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

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

변형 (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: '휴대폰'),
  ],
)

동작 스펙 (Behavior)#

인터랙션#

  • 클릭/탭: 라디오 버튼 또는 레이블 클릭 시 해당 옵션 선택
  • 호버: 포인터 오버 시 시각적 피드백 표시
  • 포커스: 포커스 링(focus ring)으로 현재 포커스된 항목 명확히 표시

상태 전환#

  • 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)#

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

키보드 인터랙션#

동작
Tab그룹 내 다음 라디오로 이동
Space현재 포커스된 항목 선택

스크린 리더#

  • Flutter: Semanticsradiobutton role, checked 상태, 그룹 레이블 전달
  • Web: role="radiogroup" / role="radio", aria-checked 자동 적용

터치 타겟#

  • 최소 터치 타겟 크기: 24×24 (WCAG 2.2 2.5.8). CoreTouchTarget.minimum 이 단일 출처이고, TouchTarget(Flutter) / coui-touch-target(Web)이 그리는 크기는 그대로 둔 채 닿는 범위만 넓힙니다. 플랫폼 가이드는 더 큰 값(iOS 44 · Android 48)을 권장하며, 컴포넌트가 그보다 크게 그리는 것은 자유입니다 — 24 는 그 아래로 내려가면 틀린 선입니다.
  • 라디오 버튼 + 레이블 전체 영역이 터치 가능 영역

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

항목FlutterWeb
클래스명RadioGroup<T>RadioGroup
아이템Radio<T>Radio
제네릭T (any type)String only
childWidget? childComponent? child
상태 컨트롤러 controller / focusNode / statesController 브라우저 기본 포커스 + DOM
uncontrolled 배관 ControlledComponentAdapter State 안 내부 선택값

레거시 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원/월'),
      ],
    ),
  ],
)