NakedToggle
Headless toggle component with configurable semantics and state management
Headless toggle component. Handles binary state management and accessibility with configurable semantics. Use builder pattern for custom styling.
Choose the right control
| 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 apps/naked_ui_example/lib/api/naked_toggle.0.dart.
Basic implementation
Toggle Button
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.
Toggle Builder State
The optional builder receives a strongly typed NakedToggleState:
isToggled→bool: current boolean valueisHovered,isFocused,isPressed,isDisabled→ derived fromNakedStatestates→ rawSet<WidgetState>if you need manual resolution
If you prefer simple composition, pass a child instead and style it externally.
Constructor
const NakedToggle({
super.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,
})Key Properties
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
Single-select Toggle Group Compatibility
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,
),
),
);
}
}Group API
const NakedToggleGroup({
super.key,
required this.child,
required this.selectedValue,
this.onChanged,
this.enabled = true,
this.orientation = Axis.horizontal,
this.loop = true,
this.semanticLabel,
this.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
Option API
const NakedToggleOption({
super.key,
required this.value,
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.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 statestates→ hover/focus/pressed/disabled flags for styling
Access from Context
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
Accessibility Notes
- 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 apps/naked_ui_example/lib/api for end-to-end scenarios.