Fliin
On this page

Components / forms

Switch

Turns a setting on or off with a tap or a drag. The track turns to the accent, a spring thumb, still when motion is reduced.

16 of 16 variants pass
Switch, settings state
Platform
Brightness
Text scale
Direction

Import

In a Flutter app where fliin init has run:

fliin add switchAlso installs: core

Examples

Settings

A settings card: each row pairs a label with a FlSwitch that applies at once. Give every switch the same label as its row, for the screen reader.

Switch, settings state
import 'package:your_app/ui/fliin/components/separator/fl_separator.dart';
import 'package:your_app/ui/fliin/components/surface/fl_surface.dart';
import 'package:your_app/ui/fliin/components/switch/fl_switch.dart';
import 'package:your_app/ui/fliin/components/text/fl_text.dart';
import 'package:your_app/ui/fliin/core/fliin.dart';
import 'package:flutter/widgets.dart';

class SwitchSettingsExample extends StatefulWidget {
  const SwitchSettingsExample({super.key});

  @override
  State<SwitchSettingsExample> createState() => _SwitchSettingsExampleState();
}

class _SwitchSettingsExampleState extends State<SwitchSettingsExample> {
  final _on = {'wifi': true, 'bluetooth': false, 'airplane': false};

  @override
  Widget build(BuildContext context) {
    final space = FlScope.of(context).space;
    return FlSurface(
      child: Column(
        mainAxisSize: MainAxisSize.min,
        spacing: space.md,
        children: [
          for (final (i, (id, label)) in [
            ('wifi', 'Wi-Fi'),
            ('bluetooth', 'Bluetooth'),
            ('airplane', 'Airplane mode'),
          ].indexed) ...[
            if (i > 0) const FlSeparator(),
            Row(
              spacing: space.md,
              children: [
                Expanded(child: FlText(label)),
                FlSwitch(
                  key: ValueKey(id),
                  value: _on[id]!,
                  label: label,
                  onChanged: (value) => setState(() => _on[id] = value),
                ),
              ],
            ),
          ],
        ],
      ),
    );
  }
}

Enabled

Switches beside their labels, one on and one off. onChanged receives the new value; keep it in your state and pass it back as value.

Switch, enabled state
import 'package:your_app/ui/fliin/components/switch/fl_switch.dart';
import 'package:your_app/ui/fliin/components/text/fl_text.dart';
import 'package:your_app/ui/fliin/core/fliin.dart';
import 'package:flutter/widgets.dart';

class SwitchEnabledExample extends StatefulWidget {
  const SwitchEnabledExample({super.key});

  @override
  State<SwitchEnabledExample> createState() => _SwitchEnabledExampleState();
}

class _SwitchEnabledExampleState extends State<SwitchEnabledExample> {
  var _wifi = true;
  var _bluetooth = false;

  @override
  Widget build(BuildContext context) {
    final space = FlScope.of(context).space;
    return Column(
      mainAxisSize: MainAxisSize.min,
      crossAxisAlignment: CrossAxisAlignment.stretch,
      spacing: space.md,
      children: [
        Row(
          spacing: space.md,
          children: [
            const Expanded(child: FlText('Wi-Fi')),
            FlSwitch(
              value: _wifi,
              label: 'Wi-Fi',
              onChanged: (value) => setState(() => _wifi = value),
            ),
          ],
        ),
        Row(
          spacing: space.md,
          children: [
            const Expanded(child: FlText('Bluetooth')),
            FlSwitch(
              value: _bluetooth,
              label: 'Bluetooth',
              onChanged: (value) => setState(() => _bluetooth = value),
            ),
          ],
        ),
      ],
    );
  }
}

On

A switch that is on: the track takes the accent and the thumb sits at the end of the reading direction.

Switch, on state
import 'package:your_app/ui/fliin/components/switch/fl_switch.dart';
import 'package:your_app/ui/fliin/components/text/fl_text.dart';
import 'package:your_app/ui/fliin/core/fliin.dart';
import 'package:flutter/widgets.dart';

class SwitchOnExample extends StatefulWidget {
  const SwitchOnExample({super.key});

  @override
  State<SwitchOnExample> createState() => _SwitchOnExampleState();
}

class _SwitchOnExampleState extends State<SwitchOnExample> {
  var _wifi = true;

  @override
  Widget build(BuildContext context) {
    return Row(
      spacing: FlScope.of(context).space.md,
      children: [
        const Expanded(child: FlText('Wi-Fi')),
        FlSwitch(
          value: _wifi,
          label: 'Wi-Fi',
          onChanged: (value) => setState(() => _wifi = value),
        ),
      ],
    );
  }
}

Off

A switch that is off: a neutral track, the thumb at the start of the reading direction.

Switch, off state
import 'package:your_app/ui/fliin/components/switch/fl_switch.dart';
import 'package:your_app/ui/fliin/components/text/fl_text.dart';
import 'package:your_app/ui/fliin/core/fliin.dart';
import 'package:flutter/widgets.dart';

class SwitchOffExample extends StatefulWidget {
  const SwitchOffExample({super.key});

  @override
  State<SwitchOffExample> createState() => _SwitchOffExampleState();
}

class _SwitchOffExampleState extends State<SwitchOffExample> {
  var _wifi = false;

  @override
  Widget build(BuildContext context) {
    return Row(
      spacing: FlScope.of(context).space.md,
      children: [
        const Expanded(child: FlText('Wi-Fi')),
        FlSwitch(
          value: _wifi,
          label: 'Wi-Fi',
          onChanged: (value) => setState(() => _wifi = value),
        ),
      ],
    );
  }
}

Disabled

onChanged: null disables a switch: it fades, keeps its value, and the screen reader announces it as unavailable.

Switch, disabled state
import 'package:your_app/ui/fliin/components/switch/fl_switch.dart';
import 'package:your_app/ui/fliin/components/text/fl_text.dart';
import 'package:your_app/ui/fliin/core/fliin.dart';
import 'package:flutter/widgets.dart';

class SwitchDisabledExample extends StatelessWidget {
  const SwitchDisabledExample({super.key});

  @override
  Widget build(BuildContext context) {
    final space = FlScope.of(context).space;
    return Column(
      mainAxisSize: MainAxisSize.min,
      crossAxisAlignment: CrossAxisAlignment.stretch,
      spacing: space.md,
      children: [
        Row(
          spacing: space.md,
          children: const [
            Expanded(child: FlText('Wi-Fi')),
            FlSwitch(value: true, label: 'Wi-Fi', onChanged: null),
          ],
        ),
        Row(
          spacing: space.md,
          children: const [
            Expanded(child: FlText('Bluetooth')),
            FlSwitch(value: false, label: 'Bluetooth', onChanged: null),
          ],
        ),
      ],
    );
  }
}

Sizes

size scales the track and the thumb: sm for a dense list, lg for a switch that carries icons. The touch target stays the platform's.

Switch, sizes state
import 'package:your_app/ui/fliin/components/switch/fl_switch.dart';
import 'package:your_app/ui/fliin/components/text/fl_text.dart';
import 'package:your_app/ui/fliin/core/fliin.dart';
import 'package:flutter/widgets.dart';

class SwitchSizesExample extends StatefulWidget {
  const SwitchSizesExample({super.key});

  @override
  State<SwitchSizesExample> createState() => _SwitchSizesExampleState();
}

class _SwitchSizesExampleState extends State<SwitchSizesExample> {
  final _on = {for (final size in FlSwitchSize.values) size: true};

  @override
  Widget build(BuildContext context) {
    final space = FlScope.of(context).space;
    return Column(
      mainAxisSize: MainAxisSize.min,
      crossAxisAlignment: CrossAxisAlignment.stretch,
      spacing: space.md,
      children: [
        for (final (size, label) in [
          (FlSwitchSize.sm, 'Small'),
          (FlSwitchSize.md, 'Medium'),
          (FlSwitchSize.lg, 'Large'),
        ])
          Row(
            spacing: space.md,
            children: [
              Expanded(child: FlText(label)),
              FlSwitch(
                value: _on[size]!,
                label: label,
                size: size,
                onChanged: (value) => setState(() => _on[size] = value),
              ),
            ],
          ),
      ],
    );
  }
}

With icons

thumbIcon draws the state in the thumb, tinted by the switch: a check or a cross, an eye open or shut, a full or an empty heart.

Switch, with-icons state
import 'package:your_app/ui/fliin/components/switch/fl_switch.dart';
import 'package:your_app/ui/fliin/core/fliin.dart';
import 'package:your_app/ui/fliin/icons/fl_icons.dart';
import 'package:flutter/widgets.dart';

class SwitchWithIconsExample extends StatefulWidget {
  const SwitchWithIconsExample({super.key});

  @override
  State<SwitchWithIconsExample> createState() => _SwitchWithIconsExampleState();
}

class _SwitchWithIconsExampleState extends State<SwitchWithIconsExample> {
  final _on = {'Power': true, 'Visible': false, 'Favorite': true};

  @override
  Widget build(BuildContext context) {
    final icons = {
      'Power': (FlIcons.check, FlIcons.xmark),
      'Visible': (FlIcons.eye, FlIcons.eyeSlash),
      'Favorite': (FlIcons.heartFill, FlIcons.heart),
    };
    return Row(
      mainAxisSize: MainAxisSize.min,
      spacing: FlScope.of(context).space.lg,
      children: [
        for (final MapEntry(key: id, value: (on, off)) in icons.entries)
          FlSwitch(
            key: ValueKey(id),
            value: _on[id]!,
            label: id,
            size: FlSwitchSize.lg,
            thumbIcon: (selected) => FlIcon(selected ? on : off),
            onChanged: (value) => setState(() => _on[id] = value),
          ),
      ],
    );
  }
}

Usage

The code the reference images render:

ColoredBox(color: FlScope.of(context).colors.surface, child: Padding(padding: EdgeInsetsDirectional.all(FlScope.of(context).space.lg), child: Column(crossAxisAlignment: CrossAxisAlignment.stretch, spacing: FlScope.of(context).space.sm, children: [for (final (fr, ar, on, enabled) in [('Wi-Fi', 'واي فاي', true, true), ('Bluetooth', 'بلوتوث', false, true), ('Airplane mode', 'وضع الطيران', true, false)]) Row(spacing: FlScope.of(context).space.md, children: [Expanded(child: Textfr, style: TextStyle(color: FlScope.of(context).colors.onSurface))), FlSwitch(value: on, label: fr, onChanged: enabled ? (_) {} : null)])])))

API

FlSwitchSize: enum {sm, md, lg}
FlSwitchSize.sm: static final FlSwitchSize
FlSwitchSize.md: static final FlSwitchSize
FlSwitchSize.lg: static final FlSwitchSize
FlSwitchSize.new: const new(double scale)
FlSwitchSize.scale: get double
FlSwitch: class extends StatefulWidget
FlSwitch.new: const new({super.key, required bool value, required ValueChanged<bool>? onChanged, required String label, bool autofocus, WidgetStatesController? statesController, FlSwitchSize size, Widget? Function(bool on)? thumbIcon})
FlSwitch.value: get bool
FlSwitch.onChanged: get ValueChanged<bool>?
FlSwitch.label: get String
FlSwitch.autofocus: get bool
FlSwitch.statesController: get WidgetStatesController?
FlSwitch.size: get FlSwitchSize
FlSwitch.thumbIcon: get Widget? Function(bool on)?
FlSwitch.createState: State<FlSwitch>()

Reference renders

The 16 images published with version 0.2.4, rendered by the shared harness on macOS with Flutter 3.47.7. fliin verify switch compares your copy to them.

iOS · Light
Switch, iOS, Light, text 1.0×, Left to right
Text 1.0× · Left to right
Switch, iOS, Light, text 1.0×, Right to left
Text 1.0× · Right to left
Switch, iOS, Light, text 2.0×, Left to right
Text 2.0× · Left to right
Switch, iOS, Light, text 2.0×, Right to left
Text 2.0× · Right to left
iOS · Dark
Switch, iOS, Dark, text 1.0×, Left to right
Text 1.0× · Left to right
Switch, iOS, Dark, text 1.0×, Right to left
Text 1.0× · Right to left
Switch, iOS, Dark, text 2.0×, Left to right
Text 2.0× · Left to right
Switch, iOS, Dark, text 2.0×, Right to left
Text 2.0× · Right to left
Android · Light
Switch, Android, Light, text 1.0×, Left to right
Text 1.0× · Left to right
Switch, Android, Light, text 1.0×, Right to left
Text 1.0× · Right to left
Switch, Android, Light, text 2.0×, Left to right
Text 2.0× · Left to right
Switch, Android, Light, text 2.0×, Right to left
Text 2.0× · Right to left
Android · Dark
Switch, Android, Dark, text 1.0×, Left to right
Text 1.0× · Left to right
Switch, Android, Dark, text 1.0×, Right to left
Text 1.0× · Right to left
Switch, Android, Dark, text 2.0×, Left to right
Text 2.0× · Left to right
Switch, Android, Dark, text 2.0×, Right to left
Text 2.0× · Right to left

Use it to

  • turn a setting on or off
  • toggle notifications
  • add an on/off switch to settings
  • enable or disable a feature
  • switch airplane mode on
  • apply a setting as soon as it is flipped
  • toggle dark mode on or off
Not for
  • a choice among several exclusive options: use radio buttons (radio)
  • accepting terms before sending: use a checkbox (checkbox)
States
  • Enabled
  • On
  • Off
  • Disabled

Source

The files fliin add switch copies into your app, before your prefix and imports are applied.

switch/fl_switch.dart

// Modified from the Switch of HeroUI Native 1.0.10, Copyright 2025 NextUI Inc.,
// licensed under Apache 2.0: https://www.apache.org/licenses/LICENSE-2.0
import 'dart:math' as math;

import 'package:flutter/widgets.dart';
import 'package:fliin_ui/core/fliin.dart';

/// The size of an [FlSwitch]: the track, the thumb and its
/// insets scale together around the `size.switch*` tokens, the touch target
/// stays that of the platform.
enum FlSwitchSize {
  /// Three quarters of the tokens: a switch in a dense list or a toolbar.
  sm(0.75),

  /// The `size.switch*` tokens.
  md(1),

  /// Five quarters of the tokens: a switch that carries icons in its thumb.
  lg(1.25);

  const FlSwitchSize(this.scale);

  /// The factor applied to the `size.switch*` tokens.
  final double scale;
}

/// An on/off switch, drawn as the canvas's `.switch`: a
/// `switchTrack` track (outlined in `danger` under an invalid
/// `FlFieldScope`) that turns `selected`, and a `switchThumb` thumb lifted
/// by the `thumb` shadow, which springs to the end. It shrinks while
/// pressed, and its thumb stretches toward the side it would go to, as
/// under a finger; it turns half opaque when disabled. A tap or a drag of the
/// thumb toggles it. [size] makes it smaller or larger; [thumbIcon] puts an
/// icon in the thumb, `selected` when on and `onSurfaceMuted` when off.
class FlSwitch extends StatefulWidget {
  /// Creates a switch in the [value] state, named [label].
  const FlSwitch({
    super.key,
    required this.value,
    required this.onChanged,
    required this.label,
    this.autofocus = false,
    this.statesController,
    this.size = FlSwitchSize.md,
    this.thumbIcon,
  });

  /// On or off. The switch does not keep the state: the caller updates it
  /// from [onChanged], and the thumb follows.
  final bool value;

  /// Receives the state requested by a tap, a drag or the keyboard. When
  /// null, or under a disabled `FlFieldScope`, the switch is disabled.
  final ValueChanged<bool>? onChanged;

  /// The switch's name for the screen reader. It is not displayed: the
  /// visible label sits beside it.
  final String label;

  /// Takes focus on first build if nothing else in its scope has it. False
  /// by default.
  final bool autofocus;

  /// A `pressed` state set here shrinks the switch like a finger resting on
  /// it: a field (`control-field`) presses it when its row is touched.
  final WidgetStatesController? statesController;

  /// The track and thumb size; `md`, the tokens, by default.
  final FlSwitchSize size;

  /// The icon in the thumb for a state (`on` true or false), at three fifths
  /// of the thumb's height, tinted `selected` when on and `onSurfaceMuted`
  /// when off: a sun and a moon, a bell and a muted bell. Null for a state:
  /// a bare thumb. When null, no icon.
  final Widget? Function(bool on)? thumbIcon;

  @override
  State<FlSwitch> createState() => _FlSwitchState();
}

class _FlSwitchState extends State<FlSwitch> with TickerProviderStateMixin, ToggleableStateMixin {
  late final AnimationController _thumb = AnimationController.unbounded(vsync: this, value: _target(widget.value));
  late final AnimationController _color = AnimationController(vsync: this, value: _target(widget.value));
  late final AnimationController _fade = AnimationController.unbounded(vsync: this, value: 1);
  late final AnimationController _press = AnimationController(vsync: this);
  double _from = 0;
  bool _pressed = false;
  FlFieldScope? _scope;
  late FlMotion _motion;

  static double _target(bool value) => value ? 1 : 0;

  @override
  ValueChanged<bool?>? get onChanged => switch (widget.onChanged) {
    final onChanged? when !(_scope?.disabled ?? false) => (value) => onChanged(value!),
    _ => null,
  };

  @override
  bool get tristate => false;

  @override
  bool? get value => widget.value;

  @override
  void initState() {
    super.initState();
    widget.statesController?.addListener(_rebuild);
  }

  @override
  void didChangeDependencies() {
    super.didChangeDependencies();
    _motion = FlMotion.of(context);
    _scope = FlFieldScope.maybeOf(context);
    if (_motion.reduced) _press.value = 0;
  }

  @override
  void didUpdateWidget(FlSwitch oldWidget) {
    super.didUpdateWidget(oldWidget);
    if (oldWidget.statesController != widget.statesController) {
      oldWidget.statesController?.removeListener(_rebuild);
      widget.statesController?.addListener(_rebuild);
    }
    final disabledMidDrag = oldWidget.onChanged != null && widget.onChanged == null;
    if (oldWidget.value != widget.value || disabledMidDrag) {
      _moveTo(_target(widget.value));
    }
  }

  void _rebuild() => setState(() {});

  /// The thumb moves on the `thumb` spring and the colors change over
  /// `toggle` along `ease`. Under reduced motion, no
  /// translation: the thumb is set in place and the old state fades out
  /// (DESIGN.md §2.4).
  void _moveTo(double target, {double? velocity}) {
    final tokens = _motion.tokens;
    if (!_motion.reduced) {
      _color.animateTo(target, duration: tokens.toggle, curve: tokens.ease);
      _thumb.driveTo(target, FlSpringMotion(tokens.thumb), velocity: velocity);
      return;
    }
    _color.value = target;
    if (_thumb.value == target) return;
    _from = _thumb.value;
    _thumb.value = target;
    _fade.value = 0;
    _fade.driveTo(1, FlFadeMotion(tokens.short3));
  }

  /// The press scale, over `short3` along `easeInOutQuad`; none under
  /// reduced motion.
  void _follow(bool pressed) {
    if (pressed == _pressed) return;
    _pressed = pressed;
    if (_motion.reduced) return;
    _press.animateTo(pressed ? 1 : 0, duration: _motion.tokens.short3, curve: _motion.tokens.easeInOutQuad);
  }

  @override
  void dispose() {
    widget.statesController?.removeListener(_rebuild);
    _press.dispose();
    _fade.dispose();
    _color.dispose();
    _thumb.dispose();
    super.dispose();
  }

  /// A capsule, unless the theme's `radiusXxl` is smaller (`square`), the
  /// thumb concentric inside.
  double _trackRadius(FlTheme theme) => math.min(theme.shape.radiusXxl, _dims(theme).height / 2);

  /// The track, the thumb and its inset at [FlSwitch.size].
  ({double width, double height, double thumbWidth, double thumbHeight, double inset}) _dims(FlTheme theme) {
    final size = theme.size;
    final scale = widget.size.scale;
    return (
      width: size.switchWidth * scale,
      height: size.switchHeight * scale,
      thumbWidth: size.switchThumbWidth * scale,
      thumbHeight: size.switchThumbHeight * scale,
      inset: size.switchThumbInset * scale,
    );
  }

  /// The thumb's travel: the track's width minus the thumb and its two
  /// insets.
  double get _travel {
    final dims = _dims(FlScope.of(context));
    return dims.width - dims.thumbWidth - 2 * dims.inset;
  }

  double _forward(BuildContext context) => switch (Directionality.of(context)) {
    TextDirection.ltr => 1,
    TextDirection.rtl => -1,
  };

  void _dragStart(DragStartDetails details) => _thumb.stop();

  void _dragUpdate(DragUpdateDetails details) {
    _thumb.value = (_thumb.value + _forward(context) * details.primaryDelta! / _travel).clamp(0, 1);
  }

  /// The thumb moves to the side where it was released, with the finger's
  /// velocity. If the parent does not follow `onChanged`, it returns to
  /// `value` on the next frame.
  void _dragEnd(DragEndDetails details) {
    final target = _thumb.value >= 0.5;
    final velocity = _forward(context) * (details.primaryVelocity ?? 0) / _travel;
    _moveTo(_target(target), velocity: velocity);
    if (target != widget.value) {
      onChanged?.call(target);
    }
    WidgetsBinding.instance.addPostFrameCallback((_) {
      if (mounted && widget.value != target) {
        _moveTo(_target(widget.value));
      }
    });
    WidgetsBinding.instance.ensureVisualUpdate();
  }

  /// The track and the thumb, the thumb at [at] of its travel and the colors
  /// at [tint] from off to on, stretched by [press] of `space.xs`.
  Widget _frame(FlTheme theme, double at, double tint, {bool hovered = false, double press = 0}) {
    final colors = theme.colors;
    final t = at.clamp(0.0, 1.0);
    final c = tint.clamp(0.0, 1.0);
    final size = theme.size;
    // Only an invalid field outlines the off track, in `danger`.
    final invalid = _scope?.invalid ?? false;
    final track = FlContinuousBorder(
      radius: _trackRadius(theme),
      side: invalid
          ? BorderSide(width: size.border, color: Color.lerp(colors.danger, colors.danger.withValues(alpha: 0), c)!)
          : BorderSide.none,
    );
    final dims = _dims(theme);
    final thumbRadius = math.max(0.0, _trackRadius(theme) - dims.inset);
    final thumb = FlContinuousBorder(radius: thumbRadius);
    // The stretch grows from the thumb's resting side: off, toward the end;
    // on, toward the start.
    final stretch = theme.space.xs * widget.size.scale * press.clamp(0.0, 1.0);
    final icon = widget.thumbIcon?.call(t >= 0.5);
    return ClipPath(
      clipper: ShapeBorderClipper(shape: track, textDirection: Directionality.of(context)),
      child: DecoratedBox(
        // Izydesk's state matrix: hovered or pressed, the off track
        // darkens to `borderSecondary`.
        decoration: ShapeDecoration(
          color: Color.lerp(hovered ? colors.borderSecondary : colors.switchTrack, colors.selected, c),
          shape: track,
        ),
        child: SizedBox(
          width: dims.width,
          height: dims.height,
          child: Stack(
            children: [
              PositionedDirectional(
                start: dims.inset + t * (_travel - stretch),
                top: dims.inset,
                width: dims.thumbWidth + stretch,
                height: dims.thumbHeight,
                child: FlShadow(
                  layers: theme.shadows.thumb,
                  // React Native shadows a view from its circular corners.
                  shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(thumbRadius)),
                  child: DecoratedBox(
                    decoration: ShapeDecoration(color: colors.switchThumb, shape: thumb),
                    child: icon == null
                        ? null
                        : Center(
                            child: IconTheme.merge(
                              data: IconThemeData(
                                // Three fifths of the thumb: a 12 icon in a 20 thumb.
                                size: dims.thumbHeight * 3 / 5,
                                color: Color.lerp(colors.onSurfaceMuted, colors.selected, c),
                              ),
                              child: icon,
                            ),
                          ),
                  ),
                ),
              ),
            ],
          ),
        ),
      ),
    );
  }

  @override
  Widget build(BuildContext context) {
    final theme = FlScope.of(context);
    _follow(downPosition != null || (widget.statesController?.value.contains(WidgetState.pressed) ?? false));
    final radius = BorderRadius.circular(_trackRadius(theme));
    final all = {...states, ...?widget.statesController?.value};
    final hovered =
        isInteractive && (all.contains(WidgetState.hovered) || all.contains(WidgetState.pressed) || _pressed);
    // Its own size even under a tight constraint (alone in a stretched
    // column): otherwise the track or the box takes the full width.
    return Align(
      alignment: AlignmentDirectional.centerStart,
      widthFactor: 1,
      heightFactor: 1,
      child: FlHitTarget(
        child: Semantics(
          toggled: widget.value,
          label: widget.label,
          child: buildToggleableWithChild(
            autofocus: widget.autofocus,
            mouseCursor: WidgetStateProperty.resolveWith(
              (states) => states.contains(WidgetState.disabled) ? MouseCursor.defer : SystemMouseCursors.click,
            ),
            child: GestureDetector(
              excludeFromSemantics: true,
              onHorizontalDragStart: isInteractive ? _dragStart : null,
              onHorizontalDragUpdate: isInteractive ? _dragUpdate : null,
              onHorizontalDragEnd: isInteractive ? _dragEnd : null,
              child: FlFocusRing(
                visible: all.contains(WidgetState.focused),
                borderRadius: radius,
                child: FlPixelSnap(
                  child: Opacity(
                    opacity: isInteractive ? 1 : theme.size.disabledOpacity,
                    child: AnimatedBuilder(
                      animation: Listenable.merge([_thumb, _color, _fade, _press]),
                      builder: (context, _) {
                        final f = _fade.value.clamp(0.0, 1.0);
                        final settled = _motion.reduced ? _thumb.value : _color.value;
                        final frame = _frame(theme, _thumb.value, settled, hovered: hovered, press: _press.value);
                        return Transform.scale(
                          scale: 1 - (1 - theme.size.controlPressedScale) * _press.value,
                          child: f == 1
                              ? frame
                              : Stack(
                                  children: [
                                    _frame(theme, _from, _from),
                                    Opacity(opacity: f, child: frame),
                                  ],
                                ),
                        );
                      },
                    ),
                  ),
                ),
              ),
            ),
          ),
        ),
      ),
    );
  }
}

Arrows to move, Enter to open, Escape to close.