RadioGroup#
여러 항목 중 하나만 선택할 수 있는 라디오 버튼 그룹 컴포넌트입니다.
Live Preview#
class RadioGroupDefaultExample extends StatefulComponent {
const RadioGroupDefaultExample({super.key});
@override
State<RadioGroupDefaultExample> createState() =>
_RadioGroupDefaultExampleState();
}
class _RadioGroupDefaultExampleState extends State<RadioGroupDefaultExample> {
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'),
],
);
}
}
class RadioGroupDefaultExample extends StatefulWidget {
const RadioGroupDefaultExample({super.key});
@override
State<RadioGroupDefaultExample> createState() =>
_RadioGroupDefaultExampleState();
}
class _RadioGroupDefaultExampleState extends State<RadioGroupDefaultExample> {
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'),
],
);
}
}
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,
),
);
}
}
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,
),
);
}
}
사용 시기 (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#
| 속성 | 타입 | 기본값 | 설명 |
|---|---|---|---|
value | T? | 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 로 유도) |
활성화 여부 |
name | String? | null | 폼 필드 이름 |
initialValue |
T? |
null |
uncontrolled 초기 선택값 — 넘기면 그룹이 자기 선택을 직접 들고 움직이고 value 는 읽지 않습니다 (아래 참고) |
controller |
RadioGroupController<T?>? |
null (Flutter 전용 런타임 인프라) |
외부 선택 상태 컨트롤러 |
focusNode |
FocusNode? |
null (Flutter 전용 런타임 인프라) |
포커스 노드 |
statesController |
WidgetStatesController? |
null (Flutter 전용 런타임 인프라) |
위젯 상태 컨트롤러 |
Radio 아이템 파라미터#
| 속성 | 타입 | 기본값 | 설명 |
|---|---|---|---|
value | T | — (required) | 이 아이템의 값 |
label | String? | 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 |
|---|---|
activeColor | primary |
borderColor | outline |
uncheckedBackground | surfaceContainer (opacity 0.3) |
disabledActiveColor | surfaceContainer |
disabledBorderColor | surfaceContainer |
disabledBackgroundColor | surfaceContainer |
disabledForegroundColor | onSurfaceVariant |
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:
Semantics로radiobuttonrole,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)#
| 항목 | Flutter | Web |
|---|---|---|
| 클래스명 | RadioGroup<T> | RadioGroup |
| 아이템 | Radio<T> | Radio |
| 제네릭 | T (any type) | String only |
| child | Widget? child | Component? child |
| 상태 컨트롤러 | controller / focusNode / statesController |
브라우저 기본 포커스 + DOM |
| uncontrolled 배관 | ControlledComponentAdapter |
State 안 내부 선택값 |
레거시 vs 통일 비교 (Migration Notes)#
이전 버전의 Web CoUI 를 참조하는 코드에는 지금의 RadioGroup 컨테이너 역할을 하던 최상위 클래스가 Radio 라는 이름이었을 수 있습니다. Flutter 쪽 이름(RadioGroup)에 맞춰 Web 도
RadioGroup 으로 개명되었고, 지금의 Radio(단수)는 그룹 안 개별 라디오 아이템을 가리키는 별도 컴포넌트입니다 — 같은 이름을 두 시점에서 다른 의미로 재사용한 것이므로 혼동하지 않도록 주의하세요.
| 항목 | 레거시 (구 Web 최상위 Radio) | 통일 RadioGroup |
|---|---|---|
| 의미 | 라디오 그룹 컨테이너 | 라디오 그룹 컨테이너 (동일 역할, 새 이름) |
| Flutter 대응 | 항상 RadioGroup | RadioGroup |
마이그레이션: 구 Web 코드의 Radio(value:, onChanged:, options: [...]) 류 최상위 사용은 RadioGroup(value:, onChanged:, children: [Radio(...), ...])
로 옮깁니다 — 오늘의 Radio 는 그 children 안에 들어가는 개별 아이템입니다.
관련 컴포넌트 (Related Components)#
- 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원/월'),
],
),
],
)