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

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(
          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:

  • 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

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

  • 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

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 externally
  • enabled → 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 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

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

  • Keyboard: Space/Enter toggles, arrow keys move between options inside a NakedToggleGroup
  • Screen readers: provide semanticLabel (and asSwitch 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.