Flutter Radio Button: Single-Select Form Controls Done Right
Radio vs RadioListTile vs custom. Group state, validation in Forms, Riverpod/Provider patterns, Semantics for accessibility — production patterns.
A flutter radio button seems trivial until you have three of them in a Form, a group that won't stay in sync, a design system requiring a different fill color, and a screen reader tester who flags announcement order issues. That's a realistic Thursday morning for our team.
Flutter ships two Radio widgets: Radio<T> and RadioListTile<T>. They handle the same data model but render very differently. On top of those, you can fully replace the visual with CustomPainter or wire the whole group into a FormField and manage selected state with setState, Provider, or Riverpod depending on group state scope. This guide covers all of it.
Radio vs RadioListTile: which one to reach for
Radio<T> renders the circular indicator only. No label, no subtitle, no leading icon. You compose it manually with a Row or a ListTile. RadioListTile<T> wraps a Radio inside a ListTile, so the entire tile is interactive by default (title, subtitle, secondary widget, and trailing radio). The tap target on RadioListTile covers the whole row, which is better for accessibility and saves you from writing a GestureDetector or InkWell.
Renders the circular indicator only. You control layout entirely. Use when you have a custom design: card-style option tiles, horizontal radio groups, grid selectors, or any layout that doesn't fit a standard list row.
Full list row: title, subtitle, secondary widget, radio. The whole tile is tappable. Use for list-style options (settings screens, survey questions) where you want accessibility for free.
One detail that trips up developers: both widgets are stateless. They render based on groupValue == value. You must hold the selected value in state yourself and call setState (or a state-management equivalent) in onChanged. If you forget to update state in onChanged, the radio appears to never change. We cover groupValue management in depth below.
flutter radio button basics: groupValue and onChanged in a working flutter radio button example
Every Radio<T> in a group shares the same groupValue and the same onChanged signature. The widget whose value matches groupValue renders as selected. When the user taps a radio, onChanged fires with that radio's value. Your job is to update state with the new value so the widget rebuilds.
// Basic flutter radio button group with setState
enum DeliverySpeed { standard, express, overnight }
class DeliveryPicker extends StatefulWidget {
const DeliveryPicker({super.key});
@override
State<DeliveryPicker> createState() => _DeliveryPickerState();
}
class _DeliveryPickerState extends State<DeliveryPicker> {
DeliverySpeed _selected = DeliverySpeed.standard;
@override
Widget build(BuildContext context) {
return Column(
children: DeliverySpeed.values.map((speed) {
return Row(
children: [
Radio<DeliverySpeed>(
value: speed,
groupValue: _selected,
onChanged: (val) {
if (val != null) setState(() => _selected = val);
},
),
Text(speed.name),
],
);
}).toList(),
);
}
} The toggleable property on Radio (false by default) lets users deselect a currently selected option by tapping it again, returning null in onChanged. In most form contexts you do not want this: a radio group should always have one selection. Leave toggleable at false unless your UI explicitly allows an empty state.
flutter radiolisttile: title, subtitle, and secondary widget
RadioListTile<T> accepts title, subtitle, secondary, and controlAffinity among its key properties. controlAffinity controls where the radio circle renders. Options: leading (left), trailing (right, the default), or platform-specific. Our team sets controlAffinity: ListTileControlAffinity.leading for most form lists because it reads left-to-right in the same order a screen reader announces it: circle first, then label.
// RadioListTile with title, subtitle, secondary icon, leading affinity
enum Plan { starter, pro, enterprise }
class PlanSelector extends StatefulWidget {
const PlanSelector({super.key});
@override
State<PlanSelector> createState() => _PlanSelectorState();
}
class _PlanSelectorState extends State<PlanSelector> {
Plan _selected = Plan.pro;
static const _details = {
Plan.starter: ('Starter', 'Up to 5 users, 10 GB storage', Icons.person),
Plan.pro: ('Pro', 'Up to 50 users, 100 GB storage', Icons.group),
Plan.enterprise: ('Enterprise', 'Unlimited users, SSO, SLA', Icons.business),
};
@override
Widget build(BuildContext context) {
return Column(
children: Plan.values.map((plan) {
final (title, subtitle, icon) = _details[plan]!;
return RadioListTile<Plan>(
value: plan,
groupValue: _selected,
onChanged: (val) {
if (val != null) setState(() => _selected = val);
},
title: Text(title),
subtitle: Text(subtitle),
secondary: Icon(icon),
// radio circle on left: cleaner for left-to-right reading
controlAffinity: ListTileControlAffinity.leading,
// tileColor highlights selected row
tileColor: _selected == plan
? Theme.of(context).colorScheme.primaryContainer
: null,
);
}).toList(),
);
}
} flutter custom radio button: override fillColor and overlayColor
Radio has three theming surfaces: fillColor (inner dot and ring), overlayColor (ripple), and RadioThemeData (global via ThemeData). You do not need a CustomPainter for most design-system overrides. MaterialStateProperty lets you specify different colors per interaction state: selected or unselected — and any interactive state.
// Custom-painted flutter radio button: fillColor + overlayColor
Radio<String>(
value: 'premium',
groupValue: _selected,
onChanged: (val) => setState(() => _selected = val!),
fillColor: MaterialStateProperty.resolveWith<Color>((states) {
if (states.contains(MaterialState.selected)) {
return const Color(0xFF6750A4); // brand purple when selected
}
if (states.contains(MaterialState.disabled)) {
return Colors.grey.shade300;
}
return Colors.grey.shade600; // unselected ring color
}),
overlayColor: MaterialStateProperty.resolveWith<Color>((states) {
if (states.contains(MaterialState.hovered)) {
return const Color(0xFF6750A4).withOpacity(0.08);
}
if (states.contains(MaterialState.pressed)) {
return const Color(0xFF6750A4).withOpacity(0.12);
}
return Colors.transparent;
}),
splashRadius: 20.0,
) For a fully custom shape (square radio, card toggle, or image-based selector), use CustomPainter inside a GestureDetector. You lose the built-in Semantics and ripple, so you must add those back manually. In practice, our team reaches for RadioThemeData and fillColor 90% of the time. CustomPainter only comes out when the design explicitly requires a non-circular indicator.
flutter radio button in a Form: FormField and validator
Radio is not a FormField. To use it inside a Flutter Form with a validator (so the form's validate() call catches an unselected group), you must wrap it in FormField<T>. Our team writes a reusable RadioGroupField<T> that takes a list of options and hooks into the Form's state.
// Radio group as a FormField — integrates with Form.validate()
class RadioGroupField<T> extends FormField<T> {
RadioGroupField({
super.key,
required List<(T, String)> options, // (value, label) tuples
T? initialValue,
super.validator,
super.onSaved,
}) : super(
initialValue: initialValue,
builder: (state) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
...options.map(
(opt) => RadioListTile<T>(
value: opt.$1,
groupValue: state.value,
onChanged: state.didChange,
title: Text(opt.$2),
controlAffinity: ListTileControlAffinity.leading,
),
),
if (state.hasError)
Padding(
padding: const EdgeInsets.only(left: 16, top: 4),
child: Text(
state.errorText!,
style: TextStyle(
color: state.context
.findAncestorStateOfType<FormState>()
?.context
.findAncestorWidgetOfExactType<Theme>() != null
? Theme.of(state.context).colorScheme.error
: Colors.red,
fontSize: 12,
),
),
),
],
);
},
);
}
// Usage inside Form:
RadioGroupField<DeliverySpeed>(
options: const [
(DeliverySpeed.standard, 'Standard (5-7 days)'),
(DeliverySpeed.express, 'Express (2-3 days)'),
(DeliverySpeed.overnight, 'Overnight'),
],
validator: (val) => val == null ? 'Select a delivery speed' : null,
onSaved: (val) => _deliverySpeed = val,
) The state.didChange callback is the key: it updates FormFieldState.value and marks the field dirty so the error clears on the next rebuild. This same FormField pattern applies to other custom form controls. We use it for checkbox groups and for expanding form sections — similar to the pattern in our flutter accordion guide where collapsible form sections are built on ExpansionTile.
flutter radio group state management: setState vs Provider vs Riverpod
setState is the right tool when the radio group is contained in a single widget with no siblings that need the selection. The moment the selected value needs to reach a parent or a data layer, lift it into a state management solution.
With Provider, hold the selected value in a ChangeNotifier. With Riverpod, a StateProvider<T?> is the minimal unit. Both patterns keep Radio stateless: they read from the provider and dispatch back to it in onChanged. The radio group itself has no local state.
// Riverpod: StateProvider for a flutter radio group
final deliverySpeedProvider = StateProvider<DeliverySpeed?>((ref) => null);
// In the widget (ConsumerWidget):
class DeliveryRadioGroup extends ConsumerWidget {
const DeliveryRadioGroup({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final selected = ref.watch(deliverySpeedProvider);
return Column(
children: DeliverySpeed.values.map((speed) {
return RadioListTile<DeliverySpeed>(
value: speed,
groupValue: selected,
onChanged: (val) {
ref.read(deliverySpeedProvider.notifier).state = val;
},
title: Text(speed.name),
controlAffinity: ListTileControlAffinity.leading,
);
}).toList(),
);
}
}
// Read the value from anywhere else in the tree:
// final speed = ref.watch(deliverySpeedProvider); One pattern we avoid: multiple radio groups on the same screen each managing their own local bool variables rather than a single typed enum or sealed class. We've seen checkout screens with payment_method_is_card and payment_method_is_bank and payment_method_is_wallet all as separate booleans, manually kept mutually exclusive with conditional setState calls. Use an enum. One value, one group, one update.
Accessibility: Semantics and keyboard navigation for flutter radio
Radio<T> adds Semantics automatically with the role inRadioGroup and a checked flag. It derives its label from the parent if composed inside a ListTile. RadioListTile<T> is better here: it derives its Semantics label from the title widget, so screen readers announce "Standard delivery, radio button, 1 of 3" without any extra code.
When you build a custom radio using Radio<T> plus a Row plus Text, the Text is a sibling of the Radio in the tree. TalkBack and VoiceOver see them as separate focusable elements. Wrap the whole Row in a Semantics widget with label set to the option text and isInMutuallyExclusiveGroup: true. This merges the two nodes into one announcement.
// Manual Semantics merge for custom Radio + Text layout
Semantics(
label: 'Express delivery, 2 to 3 days',
inMutuallyExclusiveGroup: true,
checked: _selected == DeliverySpeed.express,
onTap: () => setState(() => _selected = DeliverySpeed.express),
child: Row(
children: [
Radio<DeliverySpeed>(
value: DeliverySpeed.express,
groupValue: _selected,
onChanged: (val) {
if (val != null) setState(() => _selected = val);
},
),
ExcludeSemantics(
// label already provided by parent Semantics node
child: const Text('Express (2-3 days)'),
),
],
),
) Keyboard navigation on desktop and web: Flutter's Radio widget responds to arrow keys within a group if the FocusTraversalGroup is set to OrderedTraversalPolicy or ReadingOrderTraversalPolicy. Our team wraps radio groups in a FocusTraversalGroup to guarantee left-to-right / top-to-bottom focus order matches the visual layout.
Common pitfalls: groupValue not updating, null safety, multi-group conflicts
Across code reviews and support requests our team handles, four issues come up repeatedly for the flutter radio button pattern.
| Symptom | Root cause | Fix |
|---|---|---|
The multi-group conflict (two groups sharing the same state field) is the hardest to spot during code review because the radios in both groups visually respond to taps — they just select across groups simultaneously. Use strongly-typed enums for groupValue. If group A is DeliverySpeed and group B is PaymentMethod, the type system makes it impossible to accidentally assign the wrong group's value.
Decision matrix: which widget fits each use case — by use case
Our team reviews new screens against this matrix before picking a flutter radio variant. It saves the back-and-forth of building one approach, then rewriting it when the fit is wrong.
| Scenario | Recommended widget | Reason | |
|---|---|---|---|
| Settings screen option list (notification preferences and language settings) | RadioListTile<T> | Full row tap target. title + subtitle describe the option. No layout code required. | |
| Horizontal radio group across top of a filter bar | Radio<T> in a Row | RadioListTile defaults to vertical list layout. Use Radio directly in a Row for horizontal groups. | |
| Card-style option tiles with custom background and embedded price | Radio<T> inside a custom Card | RadioListTile cannot be restyled into a card layout. Build the card yourself and embed Radio. | |
| Radio group inside a Form that must validate before submit | RadioGroupField<T> (FormField wrapper) | Raw Radio is not a FormField. FormField wrapper hooks into Form.validate() so the group participates in form submission checks. | |
| Brand design with non-standard radio circle color or shape | Radio<T> with fillColor + RadioThemeData | fillColor via MaterialStateProperty handles color changes. CustomPainter only needed for non-circular shapes. | |
| Selection state needed across multiple screens | Radio<T> or RadioListTile<T> backed by Riverpod/Provider | setState is local only. Lift state to a provider when more than one widget reads the selection. |
GFRadio: GetWidget's themed radio component
The GetWidget Flutter UI Kit ships GFRadio, a pre-styled Radio component that extends Material's Radio with design-token-driven colors, size variants (small/medium/large) and shape types (basic, square, or fully custom). It removes the boilerplate of writing a RadioThemeData per project. The same token system drives our other form controls, including the alert widgets we cover in our flutter alert dialog guide.
// GFRadio from GetWidget — token-driven, size and shape variants
import 'package:getwidget/getwidget.dart';
GFRadio<String>(
type: GFRadioType.square, // basic | square | custom
size: GFSize.MEDIUM,
value: 'premium',
groupValue: _selected,
onChanged: (val) => setState(() => _selected = val.toString()),
activeBorderColor: GFColors.PRIMARY,
radioColor: GFColors.PRIMARY,
inactiveBorderColor: GFColors.LIGHT,
inactiveIcon: null,
) GFRadio carries the same groupValue pattern as Material's Radio, so you can mix GFRadio and RadioListTile in the same group if needed. We do this in our project starters where settings lists use RadioListTile for accessibility and card-selectors use GFRadio for custom shape. One enum, two rendering surfaces, same state.
Why is my flutter radio button not changing when I tap it?
The most common cause: onChanged updates a local variable but doesn't call setState, so the widget doesn't rebuild. Radio is stateless — it renders based on groupValue == value. If you update the value but don't trigger a rebuild, the circle stays as-is. Wrap your assignment in setState, or dispatch to a provider/notifier.
How do I make a radio button required in a Flutter Form?
Wrap your Radio group in a FormField<T> (or use the RadioGroupField pattern from this post). Provide a validator that returns an error string when the value is null. Call _formKey.currentState?.validate() on submit. FormField's built-in hasError flag triggers the error display inside the builder.
Can a flutter radio button be deselected?
Yes, if you set toggleable: true on the Radio widget. Tapping a selected radio fires onChanged with null. Default is false (once selected, the group always has a value). Leave toggleable false in most forms where an empty state is invalid.
How do I put the radio circle on the left in RadioListTile?
Set controlAffinity: ListTileControlAffinity.leading. The default is trailing (circle on right). Leading places the radio before the title, which reads more naturally left-to-right and improves screen-reader announcement order.
What's the difference between Radio<T> and RadioListTile<T>?
Radio<T> renders only the circular indicator. You compose the label and layout yourself. RadioListTile<T> wraps a Radio inside a ListTile with built-in title, subtitle, secondary, and a full-row tap target. Use Radio when you need custom layout; use RadioListTile for standard list-style option rows.
How do I use Riverpod with a flutter radio group?
Create a StateProvider<T?> for the selected value. In a ConsumerWidget, watch the provider with ref.watch and update it in onChanged with ref.read(provider.notifier).state = val. The Radio widget stays stateless — it reads from the provider and writes back on change.
How do I create a horizontal flutter radio button group?
Place Radio<T> widgets inside a Row instead of a Column. RadioListTile defaults to list (column) layout, so use Radio directly for horizontal groups. Add a Text or label widget next to each Radio using Row children. Wrap the whole Row in a Semantics with inMutuallyExclusiveGroup: true for accessibility.
Radio is stateless. It renders based on groupValue == value. Every groupValue problem is a state management problem in disguise.