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 needUseWhy
Switch visible contentNakedTabs, NakedTabBar, and NakedTabViewTabs own both selection and the associated visible panel.
Capture one exclusive form valueFlutter RadioGroup with NakedRadioRadio semantics communicate one choice from a labeled set.
Run independently combinable commandsStandalone NakedTogglesEach command, such as Bold, Italic, or Underline, keeps its own boolean state.
Preserve existing single-select segmented-button behaviorNakedToggleGroup with NakedToggleOptionThe current group is a controlled, single-value compatibility component with roving focus.

Selection model: multiple/set/toggled mode is not shipped. NakedToggleGroup accepts one controlled selectedValue; 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.

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',
            ),
          ],
        ),
      ],
    );
  }
}

Toggle Builder State

The optional builder receives a strongly typed NakedToggleState:

  • isToggledbool: current boolean value
  • isHovered, isFocused, isPressed, isDisabled → derived from NakedWidgetState
  • widgetStates → raw Set<WidgetState> if you need manual resolution

If you prefer simple composition, pass a child instead and style it externally.

Constructor

dart
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,
})

Key Properties

  • valuebool: the current on/off state (required)
  • onChangedValueChanged<bool>?: called with the next value; omit to render a disabled toggle
  • builderValueWidgetBuilder<NakedToggleState>?: tailor visuals from the typed state snapshot
  • childWidget?: static child when you do not need dynamic styling
  • asSwitchbool: false (button semantics) or true (switch semantics)
  • enabled → disable interaction entirely when false (visuals are still rendered)
  • mouseCursor → override the hover cursor when the toggle is enabled
  • enableFeedbackbool: haptic/audio feedback on activation (default true)
  • focusNode, autofocus: focus management options
  • interaction callbacks: onFocusChange, onHoverChange, onPressChange
  • semanticLabelString?: accessibility label, especially important when no text label is visible
  • excludeSemanticsbool: 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.

dart
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

dart
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 externally
  • enabled → 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 groups
  • loop → wrap arrow-key focus at the first and last enabled options (default true)
  • semanticLabel → identify the group to assistive technologies
  • excludeSemantics → hide the group and its options from the semantic tree

Option API

dart
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 child or builder; 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
  • focusNode remains caller-owned; the option manages its focusability and traversal flags while mounted
  • excludeSemantics hides one option from the semantic tree

NakedToggleOptionState<T> mirrors the base state helpers and exposes:

  • value → the option's value
  • isSelected → convenience getter for selection state
  • widgetStates → hover/focus/pressed/disabled flags for styling

Access from Context

  • NakedToggleState.of(context) / maybeOf(context) → read the nearest toggle state
  • NakedToggleOptionState.of<T>(context) / maybeOf<T>(context) → read the nearest option state
  • NakedToggleState.controllerOf(context) / maybeControllerOf(context) → obtain the shared WidgetStatesController

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 (and asSwitch when acting as a settings switch)
  • Focus management: callers that supply a FocusNode retain ownership and must dispose it
  • Visual feedback: use state.isFocused / isPressed to surface strong focus & press affordances

Need more patterns? Review the full sample app under packages/example/lib/api for end-to-end scenarios.