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 apps/naked_ui_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 optionisSelected→ convenience getter for selection statestates→ hover/focus/pressed/disabled information
Access from Context
NakedRadioState.of<T>(context)/maybeOf<T>(context)→ obtain the nearest typed stateNakedRadioState.controllerOf(context)/maybeControllerOf(context)→ access the underlyingWidgetStatesControllerwhen you need listeners outside the builder
RadioGroup Constructor
const RadioGroup({super.key, this.groupValue, required this.onChanged, required this.child})groupValue→ the selected value (nullable to allow “no selection”)onChanged→ receives new value when a radio activateschild→ subtree that contains yourNakedRadiowidgets
NakedRadio Constructor
const NakedRadio({
super.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,
this.semanticLabel,
this.excludeSemantics = false,
})Key notes:
- Provide either
child(static content) orbuilder(dynamic styling viaNakedRadioState) builder→ValueWidgetBuilder<NakedRadioState<T>>?toggleablelets the selected radio clear the group when activated again- Pass
focusNodeto integrate with external focus management - interaction callbacks:
onPressChange/onHoverChange/onFocusChange(preferbuilderpattern) semanticLabel→ override the accessible name for the radioexcludeSemantics→ hide the radio and its descendants from the semantic tree
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
FocusTraversalGroupto control traversal order
NakedRadioGroup<T> wraps Flutter’s group with an optional onChanged, group-level enabled and semanticLabel. Use it when a whole group needs disabling or an accessible name.