Headless toggle component. Handles binary state management and accessibility with configurable semantics. Use builder pattern for custom styling.
| Interaction need | Use | Why |
|---|---|---|
| Switch visible content | NakedTabs, NakedTabBar, and NakedTabView | Tabs own both selection and the associated visible panel. |
| Capture one exclusive form value | Flutter RadioGroup with NakedRadio | Radio semantics communicate one choice from a labeled set. |
| Run independently combinable commands | Standalone NakedToggles | Each command, such as Bold, Italic, or Underline, keeps its own boolean state. |
| Preserve existing single-select segmented-button behavior | NakedToggleGroup with NakedToggleOption | The current group is a controlled, single-value compatibility component with roving focus. |
Selection model: multiple/set/
toggledmode is not shipped.NakedToggleGroupaccepts one controlledselectedValue; it does not provide a multiple-selection value set or per-option toggle semantics.
Use a standalone NakedToggle for button-like feature switches and toolbar commands. Set asSwitch when the same boolean state should use switch semantics.
Complete runnable examples live in packages/example/lib/api/naked_toggle.0.dart.
import 'package:flutter/material.dart';
import 'package:naked_ui/naked_ui.dart';
class ToggleButtonExample extends StatefulWidget {
const ToggleButtonExample({super.key});
@override
State<ToggleButtonExample> createState() => _ToggleButtonExampleState();
}
class _ToggleButtonExampleState extends State<ToggleButtonExample> {
bool _isBold = false;
bool _isItalic = false;
bool _isUnderlined = false;
Widget _buildToggleButton({
required IconData icon,
required bool isSelected,
required ValueChanged<bool> onChanged,
required String tooltip,
}) {
return NakedToggle(
value: isSelected,
asSwitch: false, // Toggle button semantics
onChanged: onChanged,
semanticLabel: tooltip,
builder: (context, state, child) {
final backgroundColor = state.when(
selected: Colors.blue.shade600,
hovered: Colors.grey.shade200,
orElse: Colors.transparent,
);
final border = state.when(
focused: Border.all(color: Colors.blue, width: 2),
orElse: Border.all(color: Colors.grey.shade300),
);
return AnimatedContainer(
duration: const Duration(milliseconds: 150),
width: 40,
height: 40,
decoration: BoxDecoration(
color: backgroundColor,
borderRadius: BorderRadius.circular(6),
border: border,
boxShadow: state.isPressed
? [
BoxShadow(
color: Colors.black.withValues(alpha: 0.1),
blurRadius: 2,
offset: const Offset(0, 1),
),
]
: null,
),
child: Icon(
icon,
color: isSelected ? Colors.white : Colors.grey.shade700,
size: 18,
),
);
},
);
}
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Text Formatting',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 16),
Row(
mainAxisSize: MainAxisSize.min,
children: [
_buildToggleButton(
icon: Icons.format_bold,
isSelected: _isBold,
onChanged: (value) => setState(() => _isBold = value),
tooltip: 'Bold',
),
const SizedBox(width: 8),
_buildToggleButton(
icon: Icons.format_italic,
isSelected: _isItalic,
onChanged: (value) => setState(() => _isItalic = value),
tooltip: 'Italic',
),
const SizedBox(width: 8),
_buildToggleButton(
icon: Icons.format_underlined,
isSelected: _isUnderlined,
onChanged: (value) => setState(() => _isUnderlined = value),
tooltip: 'Underline',
),
],
),
],
);
}
}Need neutral semantics? Leave asSwitch at false for button semantics. Flip it to true for settings-style switches that announce "on/off" to assistive tech.
The optional builder receives a strongly typed NakedToggleState:
isToggled→bool: current boolean valueisHovered,isFocused,isPressed,isDisabled→ derived fromNakedWidgetStatewidgetStates→ rawSet<WidgetState>if you need manual resolution
If you prefer simple composition, pass a child instead and style it externally.
const NakedToggle({
Key? key,
required this.value,
this.onChanged,
this.child,
this.enabled = true,
this.mouseCursor,
this.enableFeedback = true,
this.focusNode,
this.autofocus = false,
this.onFocusChange,
this.onHoverChange,
this.onPressChange,
this.builder,
this.semanticLabel,
this.asSwitch = false,
this.excludeSemantics = false,
})value→bool: the current on/off state (required)onChanged→ValueChanged<bool>?: called with the next value; omit to render a disabled togglebuilder→ValueWidgetBuilder<NakedToggleState>?: tailor visuals from the typed state snapshotchild→Widget?: static child when you do not need dynamic stylingasSwitch→bool:false(button semantics) ortrue(switch semantics)enabled→ disable interaction entirely when false (visuals are still rendered)mouseCursor→ override the hover cursor when the toggle is enabledenableFeedback→bool: haptic/audio feedback on activation (defaulttrue)focusNode,autofocus: focus management options- interaction callbacks:
onFocusChange,onHoverChange,onPressChange semanticLabel→String?: accessibility label, especially important when no text label is visibleexcludeSemantics→bool: hide the toggle and its descendants from the semantic tree
NakedToggleGroup preserves the existing single-select segmented-button contract. The deliberately exclusive formatting example below demonstrates that compatibility behavior; real Bold, Italic, and Underline commands normally combine and should remain standalone NakedToggles as shown above.
import 'package:flutter/material.dart';
import 'package:naked_ui/naked_ui.dart';
class ToggleGroupExample extends StatelessWidget {
const ToggleGroupExample({super.key, required this.value, required this.onChanged});
final String value;
final ValueChanged<String> onChanged;
@override
Widget build(BuildContext context) {
return NakedToggleGroup<String>(
selectedValue: value,
onChanged: onChanged,
child: Row(
children: [
NakedToggleOption<String>(
value: 'bold',
builder: (context, state, _) {
final isCurrent = state.isSelected;
return _SegmentChip(label: 'Bold', selected: isCurrent);
},
),
const SizedBox(width: 8),
NakedToggleOption<String>(
value: 'italic',
builder: (context, state, _) {
final isCurrent = state.isSelected;
return _SegmentChip(label: 'Italic', selected: isCurrent);
},
),
],
),
);
}
}
class _SegmentChip extends StatelessWidget {
const _SegmentChip({required this.label, required this.selected});
final String label;
final bool selected;
@override
Widget build(BuildContext context) {
return AnimatedContainer(
duration: const Duration(milliseconds: 150),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
color: selected ? Colors.blue.shade600 : Colors.grey.shade200,
),
child: Text(
label,
style: TextStyle(
color: selected ? Colors.white : Colors.grey.shade800,
fontWeight: FontWeight.w600,
),
),
);
}
}const NakedToggleGroup({
Key? key,
required Widget child,
required T? selectedValue,
ValueChanged<T?>? onChanged,
bool enabled = true,
Axis orientation = Axis.horizontal,
bool loop = true,
String? semanticLabel,
bool excludeSemantics = false,
})selectedValue/onChanged→ control the currently active option externallyenabled→ cascade a disabled state to every descendant option (while still rendering them)orientation→ bind Left/Right arrows for horizontal groups or Up/Down arrows for vertical groupsloop→ wrap arrow-key focus at the first and last enabled options (defaulttrue)semanticLabel→ identify the group to assistive technologiesexcludeSemantics→ hide the group and its options from the semantic tree
const NakedToggleOption({
Key? key,
required T value,
Widget? child,
ValueWidgetBuilder<NakedToggleOptionState<T>>? builder,
bool enabled = true,
MouseCursor? mouseCursor,
bool enableFeedback = true,
FocusNode? focusNode,
bool autofocus = false,
ValueChanged<bool>? onFocusChange,
ValueChanged<bool>? onHoverChange,
ValueChanged<bool>? onPressChange,
String? semanticLabel,
bool excludeSemantics = false,
})- Provide either
childorbuilder; builders receive the typed toggle option state mouseCursor,enableFeedback, and the interaction callbacks mirror the single toggle API so you can style segmented controls consistentlyfocusNoderemains caller-owned; the option manages its focusability and traversal flags while mountedexcludeSemanticshides one option from the semantic tree
NakedToggleOptionState<T> mirrors the base state helpers and exposes:
value→ the option's valueisSelected→ convenience getter for selection statewidgetStates→ hover/focus/pressed/disabled flags for styling
NakedToggleState.of(context)/maybeOf(context)→ read the nearest toggle stateNakedToggleOptionState.of<T>(context)/maybeOf<T>(context)→ read the nearest option stateNakedToggleState.controllerOf(context)/maybeControllerOf(context)→ obtain the sharedWidgetStatesController
- Standalone keyboard: Space/Enter toggles the boolean value
- Group keyboard: one option participates in Tab traversal; Tab enters and exits the group once
- Group navigation: Left/Right follows visual direction in horizontal groups, Up/Down navigates vertical groups, and Home/End moves to the first/last enabled option
- Group selection: arrows and Home/End move focus without selecting; Space/Enter activates the focused option and proposes the next controlled value
- Group resilience: disabled options are skipped, and removing the focused option repairs focus to the next option in final visual order or its predecessor
- Screen readers: provide
semanticLabel(andasSwitchwhen acting as a settings switch) - Focus management: callers that supply a
FocusNoderetain ownership and must dispose it - Visual feedback: use
state.isFocused/isPressedto surface strong focus & press affordances
Need more patterns? Review the full sample app under packages/example/lib/api for end-to-end scenarios.