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.

flutter radio button — hero diagram

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.

Radio<T>

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.

RadioListTile<T>

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.

delivery_picker.dart
DART
// 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.

plan_selector.dart
DART
// 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_radio.dart
DART
// 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_field.dart
DART
// 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.

delivery_radio_riverpod.dart
DART
// 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.

accessible_radio_row.dart
DART
// 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.

SymptomRoot causeFix
Flutter radio button common pitfalls

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.

ScenarioRecommended widgetReason
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.
Flutter radio button variant selection by use case

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.

gf_radio_example.dart
DART
// 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.
GetWidget Engineering
RELATED

More reading.

Flutter developer cost bands by region and engagement model, editorial illustration
#flutter developer#hiring cost

How Much Does It Cost to Hire a Flutter Developer in 2026? Rates, Salary Bands, and App Development Cost

How much does it cost to hire a Flutter developer? $15–$150/hr depending on region and seniority — breakdown by India, US, and Eastern Europe rates.

Navin Sharma Navin Sharma
13m
Twelve Flutter app development companies shortlist visualization, editorial illustration
#flutter#app development

Top Flutter App Development Companies in 2026 — A Buyer's Shortlist (India + Global)

Top 10 Flutter app development companies in India (2026) — vetted by portfolio, scale, pricing, and Flutter delivery experience for production builds.

Navin Sharma Navin Sharma
14m
AI use cases across banking domains visualized as interconnected nodes, editorial illustration
#ai banking#fintech

AI in Banking — Use Cases, Named Bank Precedents, and Eval Methodology (2026)

How AI is used in banking — fraud detection, credit scoring, customer service automation, RegTech, and the use cases banks are deploying right now.

Navin Sharma Navin Sharma
19m
Curated map of AI healthcare companies grouped by category, editorial illustration
#ai healthcare#ai companies

AI Healthcare Companies in 2026 — A Curated Vendor Map (Clinical AI, Diagnostics, Drug Discovery, Mental Health)

An evaluator's shortlist of 36 named AI healthcare companies grouped by category, with the criteria we use when shortlisting vendors for hospital and health-system buyers.

Navin Sharma Navin Sharma
16m
Back to Blog