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#
코드 보기
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'),
],
);
}
}
사용 시기 (When to Use)#
이 컴포넌트를 사용하세요:
- 상호 배타적인 선택지 중 하나를 선택해야 하는 경우 (예: 성별, 결제 방법)
- 선택지가 2~6개 이하로 모든 옵션을 동시에 표시해야 하는 경우
- 사용자가 선택지를 비교하며 결정해야 하는 경우
- 선택지마다 설명을 보고 골라야 하는 경우 —
variant: card로 설명까지 한 타일에 담습니다 (예: 요금제, 배송 속도)
대신 다른 컴포넌트를 사용하세요:
Select: 선택지가 7개 이상이어서 드롭다운으로 공간을 절약해야 하는 경우Checkbox: 여러 항목을 동시에 선택할 수 있는 경우SwitchField: 켜기/끄기의 이진 선택인 경우
Props / Parameters#
T는 선택 값 타입, W는 Flutter의 Widget 또는 Web의 Component 합성 콘텐츠 타입입니다. 현재 Web RadioGroup의 값 선언은 String으로 제한되어 Flutter의 T와 타입 대칭 갭이 있습니다. 양쪽 호출을 공유해야 하는 소비자는 문자열 식별자로 매핑하며 이를 일반적인 플랫폼별 값 정책으로 의존하지 않습니다.
| 속성 | 타입 | 기본값 | 설명 |
|---|---|---|---|
value | T? | null | 현재 선택된 값 |
onChanged |
ValueChanged<T?>? |
null |
선택 변경 콜백 |
options |
List<({T value, String label})>? |
null |
간편 옵션 목록 |
child | W? | null | 커스텀 레이아웃 |
variant |
CoreRadioGroupVariant |
defaultVariant |
시맨틱 변형 (위젯 파라미터). card 는 선택지마다 테두리 있는 타일을 그립니다 (아래 「카드 (선택 타일)」) |
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 | 레이블 텍스트 |
labelMaxLines |
int? |
null |
라벨이 넘칠 때까지 허용하는 줄 수. null 은 현행대로 줄 수를 제한하지 않습니다 |
labelOverflow |
CoreTextOverflow? |
null |
labelMaxLines
에 도달한 라벨의 넘침 처리 (
clip
/
fade
/
ellipsis
/
visible
)
|
description |
String? |
null |
레이블 자리(레이블 또는
suffix
) 아래 한 단 작은 크기·더 옅은 전경으로 놓이는 보조 문장. 레이블 자리와 한 열로 쌓이며(간격 2,
card
는 4), 있으면 인디케이터가 첫 줄 위쪽에 맞춰집니다. 기본 변형에서도 쓸 수 있습니다
|
prefix | W? | 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)#
| 필드 | defaultVariant | card |
|---|---|---|
activeColor | primary | primary |
borderColor | outline | outline |
uncheckedBackground | transparent | transparent |
disabledActiveColor | disabledContent | disabledContent |
disabledBorderColor | disabledOutline | disabledOutline |
disabledBackgroundColor | disabledContainer | disabledContainer |
disabledForegroundColor | disabledContent | disabledContent |
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/:activescoped 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· rovingtabindex가 붙고, 비활성 항목에는aria-disabled="true"가 더해집니다.
그룹 이름은 Web 에서만 RadioGroup(attributes: {'aria-label': ...}) 로 루트에 실을 수 있습니다 — Flutter 에는 대응 슬롯이 없습니다.
name 을 주면 폼 제출용 히든 <input type="radio"> 가 함께 나가지만 sr-only
· tabindex="-1" · aria-hidden="true" 라 접근성 트리에는 들어가지 않습니다.
키보드#
| 키 | Flutter | Web |
|---|---|---|
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)#
| 항목 | 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 안 내부 선택값 |
| 링 굵기 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 대응 | 항상 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원/월'),
],
),
],
)
선택 카드 패딩을 별도로 지정하기#
기본 카드 라디오는 선택 테두리가 두꺼워져도 높이와 콘텐츠 위치를 유지합니다.
시안이 선택 상태에서 다른 내부 패딩이나 외곽 크기를 요구할 때만
tileSelectedPadding을 지정합니다. 예를 들어 일반/선택 패딩을 모두
CoreEdgeInsets.symmetric(horizontal: 16, vertical: 14)로 하고 경계를
각각1/1.5로 지정하면 선택 카드가1px 더 높아집니다. 미지정은 기존 동작을
그대로 유지합니다. Flutter와 Web은 같은 슬롯을 사용하고 비활성 선택에도
같은 배치가 적용됩니다. 의미·선택·키보드 동작은 바뀌지 않습니다.