How to Implement a Flutter Radio Button Group for User Selections

Use RadioGroup<T> as an inherited container to manage the groupValue and onChanged callback for all child radio widgets, eliminating the need to pass state to individual Radio or RadioListTile instances.

Flutter provides a modern, centralized approach to handling mutually exclusive selections through the RadioGroup<T> widget. According to the flutter/flutter repository, this widget acts as a state container that automatically manages keyboard navigation, accessibility semantics, and single-selection enforcement for any radio-style widgets nested within it.

Understanding the RadioGroup Architecture

The RadioGroup<T> widget, implemented in packages/flutter/lib/src/widgets/radio_group.dart, serves as an inherited widget that maintains a single groupValue of type T and broadcasts changes through an onChanged callback.

When a Radio<T> or RadioListTile<T> is built inside a RadioGroup, it registers itself using the RadioClient<T> mixin. This registration enables the group to:

  • Enforce single-selection: A post-frame check guarantees that only one radio button matches the current groupValue (see lines 24-31 in radio_group.dart)
  • Manage focus traversal: The custom _SkipUnselectedRadioPolicy ensures that keyboard navigation (Tab key) lands on the currently selected radio, or the first radio if none is selected (lines 43-55)
  • Handle keyboard input: Arrow keys and Space/Enter interactions are managed at the group level with proper ARIA semantics

Individual Radio widgets, defined in packages/flutter/lib/src/material/radio.dart, automatically read the nearest RadioGroup context to determine their visual state. Note that the groupValue and onChanged parameters on individual Radio widgets are now deprecated, making RadioGroup the recommended approach for state management.

Setting Up Your Flutter Radio Button Group

To implement a radio button group in your Flutter application:

  1. Declare a state variable to hold the selected value (can be nullable to support no selection)

  2. Wrap your radio widgets with RadioGroup<T>, passing the state variable as groupValue and a callback to update state

  3. Assign unique values to each Radio or RadioListTile child—matching values render as selected

The RadioGroup supports Material Radio<T>, CupertinoRadio<T>, RawRadio<T>, and the convenience wrapper RadioListTile<T>. For toggleable behavior (allowing users to deselect by tapping the selected option), set toggleable: true on individual Radio widgets, which produces a null value in the onChanged callback.

Complete Implementation Examples

Basic Enum-Based Selection

This example demonstrates the standard pattern using RadioListTile with an enum type:

enum Fruit { apple, banana, orange }

class FruitSelector extends StatefulWidget {
  const FruitSelector({super.key});
  @override
  State<FruitSelector> createState() => _FruitSelectorState();
}

class _FruitSelectorState extends State<FruitSelector> {
  Fruit? _selectedFruit; // holds the group value

  @override
  Widget build(BuildContext context) {
    return RadioGroup<Fruit>(
      groupValue: _selectedFruit,
      onChanged: (Fruit? newValue) => setState(() => _selectedFruit = newValue),
      child: Column(
        children: <Widget>[
          RadioListTile<Fruit>(
            title: const Text('Apple'),
            value: Fruit.apple,
            // No need for groupValue/onChanged here – the group handles it
          ),
          RadioListTile<Fruit>(
            title: const Text('Banana'),
            value: Fruit.banana,
          ),
          RadioListTile<Fruit>(
            title: const Text('Orange'),
            value: Fruit.orange,
          ),
        ],
      ),
    );
  }
}

Custom Radio Buttons with Toggleable Options

For custom layouts using the base Radio widget with deselection support:

class ColorChoice extends StatefulWidget {
  const ColorChoice({super.key});
  @override
  State<ColorChoice> createState() => _ColorChoiceState();
}

class _ColorChoiceState extends State<ColorChoice> {
  String? _choice; // group value

  @override
  Widget build(BuildContext context) {
    return RadioGroup<String>(
      groupValue: _choice,
      onChanged: (String? newValue) => setState(() => _choice = newValue),
      child: Row(
        children: [
          Column(
            children: [
              Radio<String>(value: 'red'),
              const Text('Red'),
            ],
          ),
          Column(
            children: [
              Radio<String>(value: 'green'),
              const Text('Green'),
            ],
          ),
          Column(
            children: [
              Radio<String>(value: 'blue', toggleable: true),
              const Text('Blue (toggleable)'),
            ],
          ),
        ],
      ),
    );
  }
}

Accessing Selection State Programmatically

To read the current selection from a descendant widget in the tree without passing callbacks:

void _showSelection(BuildContext context) {
  final selected = RadioGroup.maybeOf<String>(context)?.groupValue;
  ScaffoldMessenger.of(context).showSnackBar(
    SnackBar(content: Text('Selected: ${selected ?? "none"}')),
  );
}

Key Features and Accessibility

The flutter/flutter source code provides several built-in optimizations for production applications:

  • Semantic wrapping: RadioGroup automatically wraps children with proper Semantics nodes for screen reader compatibility
  • Type safety: The generic <T> parameter ensures that all radios in a group share the same value type, preventing runtime errors
  • Performance: State changes trigger minimal rebuilds because individual Radio widgets subscribe to the inherited RadioGroup rather than receiving explicit parameters

Refer to examples/api/lib/widgets/radio_group/radio_group.0.dart in the Flutter repository for a minimal runnable example that demonstrates the public API.

Summary

  • Centralize state with RadioGroup<T> instead of managing groupValue on individual Radio widgets
  • Automatic registration occurs when Radio or RadioListTile widgets are descendants of a RadioGroup
  • Keyboard navigation and focus management are handled internally by the group's _SkipUnselectedRadioPolicy
  • Toggleable support allows null values when users deselect the current option
  • Deprecated parameters: Avoid using groupValue and onChanged directly on Radio widgets; these are maintained for backward compatibility only

Frequently Asked Questions

How do I allow users to deselect a radio button in Flutter?

Set the toggleable parameter to true on the specific Radio widget where you want to enable deselection. When the user taps an already-selected toggleable radio, the onChanged callback receives null, allowing you to clear the groupValue in your state management.

What is the difference between Radio and RadioListTile in a RadioGroup?

Radio provides the core circular selection indicator without additional UI elements, while RadioListTile combines a Radio with a title, subtitle, and optional secondary widget in a ListTile layout. Both widgets register with the nearest RadioGroup automatically and read the groupValue from the inherited context.

Why am I getting deprecation warnings on my Radio widgets?

As of recent Flutter versions, the groupValue and onChanged parameters on individual Radio widgets are deprecated in favor of the RadioGroup container. Move these parameters to the RadioGroup ancestor, and remove them from the individual Radio instances to resolve the warnings and follow current best practices.

How does RadioGroup handle accessibility for screen readers?

The RadioGroup widget in packages/flutter/lib/src/widgets/radio_group.dart automatically wraps its children with Semantics nodes that implement proper ARIA radio group semantics. It manages focus traversal so that screen readers announce the group context when users navigate between options using keyboard or gesture navigation.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →