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.
When to use this
- Toolbar toggles: Bold, italic, underline buttons in editors
- View toggles: Switch between list/grid views
- Feature toggles: Turn features on/off with button-like appearance
- Multi-state controls: When you need both button and switch semantics
Complete runnable examples live in example/lib/api/naked_toggle.0.dart
.
Basic implementation
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(
toggled: 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 fromNakedWidgetState
widgetStates
→ rawSet<WidgetState>
if you need manual resolution
If you prefer simple composition, pass a child
instead and style it externally.
Constructor
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,
})
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 visible
Toggle Groups & Segmented Controls
Combine NakedToggleGroup
and NakedToggleOption
for radio-like segmented controls:
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: 'grid',
builder: (context, state, _) {
final isCurrent = state.isSelected;
return _SegmentChip(label: 'Grid', selected: isCurrent);
},
),
const SizedBox(width: 8),
NakedToggleOption<String>(
value: 'list',
builder: (context, state, _) {
final isCurrent = state.isSelected;
return _SegmentChip(label: 'List', 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: const Text(
label,
style: TextStyle(
color: selected ? Colors.white : Colors.grey.shade800,
fontWeight: FontWeight.w600,
),
),
);
}
}
Group API
const NakedToggleGroup({
Key? key,
required Widget child,
required T? selectedValue,
ValueChanged<T?>? onChanged,
bool enabled = true,
})
selectedValue
/onChanged
→ control the currently active option externallyenabled
→ cascade a disabled state to every descendant option (while still rendering them)
Option API
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,
})
- Provide either
child
orbuilder
; builders receive the typed toggle option state mouseCursor
,enableFeedback
, and the interaction callbacks mirror the single toggle API so you can style segmented controls consistently
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
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
- Keyboard: Space/Enter toggles, arrow keys move between options inside a
NakedToggleGroup
- Screen readers: provide
semanticLabel
(andasSwitch
when acting as a settings switch) - Focus management: supply your own
FocusNode
when integrating with larger focus scopes - Visual feedback: use
state.isFocused
/isPressed
to surface strong focus & press affordances
Need more patterns? Review the full sample app under example/lib/api
for end-to-end scenarios.