NakedRadio

Headless radio button primitives backed by RadioGroup for exclusive selection

Headless radio button component. RadioGroup coordinates keyboard navigation and exclusive selection. NakedRadio provides state for custom styling.

When to use this

  • Single selection: Choose one option from multiple choices
  • Settings: Select one preference from a group (theme, language, etc.)
  • Forms: Required single-choice fields
  • Filters: Select one filter option from a category

Full examples are available in example/lib/api/naked_radio.0.dart.

Basic implementation

import 'package:flutter/material.dart';
import 'package:naked_ui/naked_ui.dart';

enum RadioOption { banana, apple }

class RadioExample extends StatefulWidget {
  const RadioExample({super.key});

  @override
  State<RadioExample> createState() => _RadioExampleState();
}

class _RadioExampleState extends State<RadioExample> {
  RadioOption _selected = RadioOption.banana;

  @override
  Widget build(BuildContext context) {
    return RadioGroup<RadioOption>(
      groupValue: _selected,
      onChanged: (value) => setState(() => _selected = value!),
      child: const Row(
        mainAxisSize: MainAxisSize.min,
        spacing: 12,
        children: [
          _RadioChip(value: RadioOption.banana, label: 'Banana'),
          _RadioChip(value: RadioOption.apple, label: 'Apple'),
        ],
      ),
    );
  }
}

class _RadioChip extends StatelessWidget {
  const _RadioChip({required this.value, required this.label});

  final RadioOption value;
  final String label;

  @override
  Widget build(BuildContext context) {
    return NakedRadio<RadioOption>(
      value: value,
      builder: (context, state, _) {
        final bool isFocused = state.isFocused;

        final backgroundColor = state.when(
          selected: Colors.blue.shade600,
          hovered: Colors.grey.shade200,
          orElse: Colors.white,
        );

        return AnimatedContainer(
          duration: const Duration(milliseconds: 150),
          padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
          decoration: BoxDecoration(
            borderRadius: BorderRadius.circular(20),
            color: backgroundColor,
            border: Border.all(
              color: state.when(
                focused: Colors.blue,
                orElse: Colors.grey.shade300,
              ),
              width: isFocused ? 2 : 1,
            ),
          ),
          child: Row(
            mainAxisSize: MainAxisSize.min,
            children: [
              Text(
                label,
                style: TextStyle(
                  color: state.isSelected ? Colors.white : Colors.grey.shade800,
                  fontWeight: FontWeight.w600,
                ),
              ),
              if (state.isSelected) ...[
                const SizedBox(width: 6),
                const Icon(Icons.check, size: 16, color: Colors.white),
              ],
            ],
          ),
        );
      },
    );
  }
}

Typed Radio State

NakedRadioState<T> (delivered to the builder) exposes:

  • value → radio value for this option
  • isSelected → convenience getter for selection state
  • widgetStates → hover/focus/pressed/disabled information

Access from Context

  • NakedRadioState.of<T>(context) / maybeOf<T>(context) → obtain the nearest typed state
  • NakedRadioState.controllerOf(context) / maybeControllerOf(context) → access the underlying WidgetStatesController when you need listeners outside the builder

RadioGroup Constructor

const RadioGroup({
  Key? key,
  required this.groupValue,
  required this.onChanged,
  required this.child,
})
  • groupValue → the selected value (nullable to allow “no selection”)
  • onChanged → receives new value when a radio activates
  • child → subtree that contains your NakedRadio widgets

NakedRadio Constructor

const NakedRadio<T>({
  Key? key,
  required this.value,
  this.child,
  this.enabled = true,
  this.mouseCursor,
  this.focusNode,
  this.autofocus = false,
  this.toggleable = false,
  this.onFocusChange,
  this.onHoverChange,
  this.onPressChange,
  this.builder,
  this.groupRegistry,
})

Key notes:

  • Provide either child (static content) or builder (dynamic styling via NakedRadioState)
  • builderValueWidgetBuilder<NakedRadioState<T>>?
  • toggleable lets the selected radio clear the group when activated again
  • Pass focusNode to integrate with external focus management
  • interaction callbacks: onPressChange/onHoverChange/onFocusChange (prefer builder pattern)

Accessibility Tips

  • Ensure each radio exposes a label (e.g. text within the builder)
  • Default keyboard support: arrow keys move between radios, Space selects
  • Keep your focus/hover styles visible using state.isFocused / state.isHovered
  • For more advanced layouts, wrap radio groups in FocusTraversalGroup to control traversal order