Fliin
On this page

Components / forms

Color slider

Sets one channel of a color, hue, saturation, brightness, lightness or opacity, on a track painted with the colors it goes through.

16 of 16 variants pass
Color slider, example state
Platform
Brightness
Text scale
Direction

Import

In a Flutter app where fliin init has run:

fliin add color-sliderAlso installs: core, color-swatch

Examples

Example

Three sliders over one HSVColor: hue, then saturation and lightness in the HSL space. Each slider paints its track from the current color.

Color slider, example state
import 'package:your_app/ui/fliin/components/color_slider/fl_color_slider.dart';
import 'package:your_app/ui/fliin/components/color_swatch/fl_color_channel.dart';
import 'package:your_app/ui/fliin/core/fliin.dart';
import 'package:flutter/widgets.dart';

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

  @override
  State<ColorSliderExample> createState() => _ColorSliderExampleState();
}

class _ColorSliderExampleState extends State<ColorSliderExample> {
  var _color = const HSVColor.fromAHSV(1, 0, 1, 1);

  @override
  Widget build(BuildContext context) {
    return Column(
      mainAxisSize: MainAxisSize.min,
      crossAxisAlignment: CrossAxisAlignment.stretch,
      spacing: FlScope.of(context).space.md,
      children: [
        FlColorSlider(
          key: const ValueKey('hue'),
          value: _color,
          channel: FlColorChannel.hue,
          label: 'Hue',
          onChanged: (color) => setState(() => _color = color),
        ),
        FlColorSlider(
          value: _color,
          channel: FlColorChannel.saturation,
          space: FlColorSpace.hsl,
          label: 'Saturation',
          onChanged: (color) => setState(() => _color = color),
        ),
        FlColorSlider(
          key: const ValueKey('lightness'),
          value: _color,
          channel: FlColorChannel.lightness,
          space: FlColorSpace.hsl,
          label: 'Lightness',
          onChanged: (color) => setState(() => _color = color),
        ),
      ],
    );
  }
}

Hue

The hue channel: the track runs through the whole color wheel.

Color slider, hue state
import 'package:your_app/ui/fliin/components/color_slider/fl_color_slider.dart';
import 'package:your_app/ui/fliin/components/color_swatch/fl_color_channel.dart';
import 'package:flutter/widgets.dart';

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

  @override
  State<ColorSliderHueExample> createState() => _ColorSliderHueExampleState();
}

class _ColorSliderHueExampleState extends State<ColorSliderHueExample> {
  var _color = const HSVColor.fromAHSV(1, 217, 0.76, 0.96);

  @override
  Widget build(BuildContext context) {
    return Column(
      mainAxisSize: MainAxisSize.min,
      crossAxisAlignment: CrossAxisAlignment.stretch,
      children: [
        FlColorSlider(
          value: _color,
          channel: FlColorChannel.hue,
          label: 'Hue',
          onChanged: (color) => setState(() => _color = color),
        ),
      ],
    );
  }
}

Saturation

The saturation channel, from gray to the full color.

Color slider, saturation state
import 'package:your_app/ui/fliin/components/color_slider/fl_color_slider.dart';
import 'package:your_app/ui/fliin/components/color_swatch/fl_color_channel.dart';
import 'package:flutter/widgets.dart';

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

  @override
  State<ColorSliderSaturationExample> createState() => _ColorSliderSaturationExampleState();
}

class _ColorSliderSaturationExampleState extends State<ColorSliderSaturationExample> {
  var _color = const HSVColor.fromAHSV(1, 217, 0.76, 0.96);

  @override
  Widget build(BuildContext context) {
    return Column(
      mainAxisSize: MainAxisSize.min,
      crossAxisAlignment: CrossAxisAlignment.stretch,
      children: [
        FlColorSlider(
          value: _color,
          channel: FlColorChannel.saturation,
          label: 'Saturation',
          onChanged: (color) => setState(() => _color = color),
        ),
      ],
    );
  }
}

Alpha

The alpha channel over a checkerboard, for a color that may be transparent.

Color slider, alpha state
import 'package:your_app/ui/fliin/components/color_slider/fl_color_slider.dart';
import 'package:your_app/ui/fliin/components/color_swatch/fl_color_channel.dart';
import 'package:flutter/widgets.dart';

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

  @override
  State<ColorSliderAlphaExample> createState() => _ColorSliderAlphaExampleState();
}

class _ColorSliderAlphaExampleState extends State<ColorSliderAlphaExample> {
  var _color = const HSVColor.fromAHSV(0.5, 217, 0.76, 0.96);

  @override
  Widget build(BuildContext context) {
    return Column(
      mainAxisSize: MainAxisSize.min,
      crossAxisAlignment: CrossAxisAlignment.stretch,
      children: [
        FlColorSlider(
          value: _color,
          channel: FlColorChannel.alpha,
          label: 'Opacity',
          onChanged: (color) => setState(() => _color = color),
        ),
      ],
    );
  }
}

Disabled

onChanged: null disables the slider: it fades and ignores touches.

Color slider, disabled state
import 'package:your_app/ui/fliin/components/color_slider/fl_color_slider.dart';
import 'package:your_app/ui/fliin/components/color_swatch/fl_color_channel.dart';
import 'package:flutter/widgets.dart';

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

  @override
  State<ColorSliderDisabledExample> createState() => _ColorSliderDisabledExampleState();
}

class _ColorSliderDisabledExampleState extends State<ColorSliderDisabledExample> {
  final _color = const HSVColor.fromAHSV(1, 217, 0.76, 0.96);

  @override
  Widget build(BuildContext context) {
    return Column(
      mainAxisSize: MainAxisSize.min,
      crossAxisAlignment: CrossAxisAlignment.stretch,
      children: [
        FlColorSlider(
          value: _color,
          channel: FlColorChannel.brightness,
          label: 'Brightness',
          onChanged: null,
        ),
      ],
    );
  }
}

Vertical

axis: Axis.vertical stands the sliders up; they need a bounded height. showLabel: false keeps the track alone, side by side here.

Color slider, vertical state
import 'package:your_app/ui/fliin/components/color_slider/fl_color_slider.dart';
import 'package:your_app/ui/fliin/components/color_swatch/fl_color_channel.dart';
import 'package:your_app/ui/fliin/core/fliin.dart';
import 'package:flutter/widgets.dart';

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

  @override
  State<ColorSliderVerticalExample> createState() => _ColorSliderVerticalExampleState();
}

class _ColorSliderVerticalExampleState extends State<ColorSliderVerticalExample> {
  var _color = const HSVColor.fromAHSV(0.8, 217, 0.76, 0.96);

  @override
  Widget build(BuildContext context) {
    return SizedBox(
      height: 200,
      child: Row(
        mainAxisSize: MainAxisSize.min,
        spacing: FlScope.of(context).space.xl,
        children: [
          for (final (channel, label) in [
            (FlColorChannel.hue, 'Hue'),
            (FlColorChannel.saturation, 'Saturation'),
            (FlColorChannel.brightness, 'Brightness'),
            (FlColorChannel.alpha, 'Opacity'),
          ])
            FlColorSlider(
              value: _color,
              channel: channel,
              label: label,
              axis: Axis.vertical,
              showLabel: false,
              onChanged: (color) => setState(() => _color = color),
            ),
        ],
      ),
    );
  }
}

Usage

The code the reference images render:

final example = Builder(
  builder: (context) {
    final rtl = variant.dir == TextDirection.rtl;
    final theme = FlScope.of(context);
    String tr(String fr, String ar) => rtl ? ar : fr;
    const red = HSVColor.fromAHSV(1, 0, 1, 1);
    const blue = HSVColor.fromAHSV(0.5, 217, 0.76, 0.96);
    return ColoredBox(
      color: theme.colors.surface,
      child: SingleChildScrollView(
        padding: EdgeInsetsDirectional.all(theme.space.lg),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.stretch,
          spacing: theme.space.lg,
          children: [
            FlColorSlider(
              value: red,
              channel: FlColorChannel.hue,
              label: tr('Hue', 'الصبغة'),
              onChanged: (_) {},
            ),
            FlColorSlider(
              value: red,
              channel: FlColorChannel.saturation,
              space: FlColorSpace.hsl,
              label: tr('Saturation', 'التشبع'),
              onChanged: (_) {},
            ),
            FlColorSlider(
              value: red,
              channel: FlColorChannel.lightness,
              space: FlColorSpace.hsl,
              label: tr('Lightness', 'الإضاءة'),
              onChanged: (_) {},
            ),
            FlColorSlider(
              value: blue,
              channel: FlColorChannel.alpha,
              label: tr('Opacity', 'العتامة'),
              onChanged: (_) {},
            ),
            FlColorSlider(
              value: blue,
              channel: FlColorChannel.brightness,
              label: tr('Brightness', 'السطوع'),
              onChanged: null,
            ),
            SizedBox(
              height: 160,
              child: Row(
                spacing: theme.space.xl,
                children: [
                  FlColorSlider(
                    value: blue,
                    channel: FlColorChannel.hue,
                    axis: Axis.vertical,
                    showLabel: false,
                    label: tr('Hue', 'الصبغة'),
                    onChanged: (_) {},
                  ),
                  FlColorSlider(
                    value: blue,
                    channel: FlColorChannel.alpha,
                    axis: Axis.vertical,
                    label: tr('Opacity', 'العتامة'),
                    onChanged: (_) {},
                  ),
                ],
              ),
            ),
          ],
        ),
      ),
    );
  },
);

API

flColorTrack: List<Color>(HSVColor value, FlColorChannel channel, FlColorSpace space)
flColorThumbColor: Color(HSVColor value, FlColorChannel channel)
FlColorSlider: class extends StatefulWidget
FlColorSlider.new: const new({super.key, required HSVColor value, required FlColorChannel channel, required ValueChanged<HSVColor>? onChanged, required String label, ValueChanged<HSVColor>? onChangeEnd, FlColorSpace space, Axis axis, bool showLabel, String Function(double value)? valueLabel, bool autofocus})
FlColorSlider.value: get HSVColor
FlColorSlider.channel: get FlColorChannel
FlColorSlider.space: get FlColorSpace
FlColorSlider.onChanged: get ValueChanged<HSVColor>?
FlColorSlider.onChangeEnd: get ValueChanged<HSVColor>?
FlColorSlider.label: get String
FlColorSlider.axis: get Axis
FlColorSlider.showLabel: get bool
FlColorSlider.valueLabel: get String Function(double value)?
FlColorSlider.autofocus: get bool
FlColorSlider.createState: State<FlColorSlider>()

Reference renders

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

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

Use it to

  • adjust the hue of a colour along a rainbow track
  • change how saturated or light a colour is
  • change the opacity of a colour over a checkerboard
  • show the colour channel value in degrees or percent
  • use a vertical colour slider
Not for
  • a value that is not a color, such as a volume: use a slider (slider)
  • saturation and brightness together: use a color area (color-area)
  • a full choice with the area and the code: use a color picker (color-picker)
States
  • Hue
  • Saturation
  • Alpha
  • Disabled

Source

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

color_slider/fl_color_slider.dart

// Modified from the ColorSlider of HeroUI React v3, 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/gestures.dart' show DragStartBehavior;
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
import 'package:fliin_ui/components/color_swatch/fl_color_swatch.dart';
import 'package:fliin_ui/core/fliin.dart';

/// An arrow moves one degree of hue or one percent; Page, ten times as much.
const int _pageSteps = 10;

/// The stops of a channel's track: the hue goes round the wheel in six
/// sixths, the HSL lightness through the pure tint at its middle; the other
/// channels are linear in RGB.
int _stopsOf(FlColorChannel channel) => switch (channel) {
  FlColorChannel.hue => 7,
  FlColorChannel.lightness => 3,
  _ => 2,
};

/// The colors of [channel]'s track from 0 to its maximum, the other channels
/// those of [value]. The hue track is the full wheel whatever the color, as
/// in React Aria; the others are opaque, except alpha's.
List<Color> flColorTrack(HSVColor value, FlColorChannel channel, FlColorSpace space) {
  final stops = _stopsOf(channel);
  final base = switch (channel) {
    FlColorChannel.hue => HSVColor.fromAHSV(1, value.hue, 1, 1),
    FlColorChannel.alpha => value,
    _ => value.withAlpha(1),
  };
  return [for (var i = 0; i < stops; i++) base.withChannel(channel, space, channel.max * i / (stops - 1)).toColor()];
}

/// The color a thumb shows for [value] on [channel]: the pure hue on the hue
/// track, the translucent color on alpha's, the opaque color elsewhere
/// (React Aria's `getDisplayColor`).
Color flColorThumbColor(HSVColor value, FlColorChannel channel) => switch (channel) {
  FlColorChannel.hue => HSVColor.fromAHSV(1, value.hue, 1, 1).toColor(),
  FlColorChannel.alpha => value.toColor(),
  _ => value.withAlpha(1).toColor(),
};

class _StepIntent extends Intent {
  const _StepIntent(this.steps);

  final int steps;
}

class _EdgeIntent extends Intent {
  const _EdgeIntent({required this.end});

  final bool end;
}

/// A slider over one channel of a color: hue,
/// saturation, brightness, lightness or opacity, on a track painted with
/// the colors that channel goes through, computed from [value]. The thumb,
/// a 16 disc ringed with white, shows the color it picks; the track's ends
/// are rounded caps of its first and last colors, so that the thumb's
/// center reaches both bounds without leaving the track.
///
/// The color is a value of the app, held as an [HSVColor] (a gray keeps
/// its hue); none of its tints comes from the theme. The slider does not
/// keep it: the parent gives back in [value] what [onChanged] receives.
///
/// The screen reader reads a slider named [label] whose value is the
/// channel's ("210°", "40%"), adjusted by one degree or one percent; the
/// keyboard moves it by as much with the arrows, ten times as much with
/// Page, to a bound with Home and End.
class FlColorSlider extends StatefulWidget {
  /// A slider over [channel] of [value], read in [space].
  const FlColorSlider({
    super.key,
    required this.value,
    required this.channel,
    required this.onChanged,
    required this.label,
    this.onChangeEnd,
    this.space = FlColorSpace.hsb,
    this.axis = Axis.horizontal,
    this.showLabel = true,
    this.valueLabel,
    this.autofocus = false,
  }) : assert(channel != FlColorChannel.brightness || space == FlColorSpace.hsb, 'brightness is a channel of hsb'),
       assert(channel != FlColorChannel.lightness || space == FlColorSpace.hsl, 'lightness is a channel of hsl');

  /// The color shown; the slider moves its [channel] only.
  final HSVColor value;

  /// The channel the slider moves.
  final FlColorChannel channel;

  /// The space [channel] is read in: `hsb` by default.
  final FlColorSpace space;

  /// Receives the color with the new channel value while dragging, on tap
  /// and from the keyboard; null, the slider is disabled and fades to
  /// `size.disabledOpacity`.
  final ValueChanged<HSVColor>? onChanged;

  /// Receives the color when a drag ends, after a tap or a key press.
  final ValueChanged<HSVColor>? onChangeEnd;

  /// The slider's name ("Hue"), shown above the track when [showLabel] is
  /// true and read by the screen reader.
  final String label;

  /// Horizontal by default; vertical, the value grows upward and the slider
  /// needs a bounded height.
  final Axis axis;

  /// True (the default): [label] at the start and the value at the end,
  /// above the track; false, the
  /// track alone, as in a color picker.
  final bool showLabel;

  /// The channel's value as written and spoken; null, [flColorChannelLabel] ("210°", "40%").
  final String Function(double value)? valueLabel;

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

  @override
  State<FlColorSlider> createState() => _FlColorSliderState();
}

class _FlColorSliderState extends State<FlColorSlider> {
  final GlobalKey _track = GlobalKey();
  final FocusNode _focus = FocusNode(debugLabel: 'FlColorSlider');
  bool _focused = false;
  bool _dragging = false;

  /// The last color sent to the parent, until it rebuilds: two key presses
  /// in the same frame add up.
  HSVColor? _sent;

  bool get _enabled => widget.onChanged != null;

  FlColorChannel get _channel => widget.channel;

  HSVColor get _value => _sent ?? widget.value;

  double get _current => _value.channel(_channel, widget.space);

  @override
  void didUpdateWidget(FlColorSlider oldWidget) {
    super.didUpdateWidget(oldWidget);
    _sent = null;
  }

  double get _step => _channel == FlColorChannel.hue ? 1 : 0.01;

  @override
  void dispose() {
    _focus.dispose();
    super.dispose();
  }

  String _describe(double value) => widget.valueLabel?.call(value) ?? flColorChannelLabel(_channel, value);

  HSVColor _with(double value) => _value.withChannel(_channel, widget.space, value);

  void _change(double value) {
    final next = _with(value);
    if (next == _value) return;
    _sent = next;
    widget.onChanged?.call(next);
  }

  void _commit(double value) {
    _change(value);
    widget.onChangeEnd?.call(_with(value));
  }

  /// The channel's value under [global]: the thumb's center travels from
  /// one cap's middle to the other's.
  double? _valueAt(Offset global, double cap) {
    final box = _track.currentContext?.findRenderObject();
    if (box is! RenderBox || !box.hasSize) return null;
    final local = box.globalToLocal(global);
    final ltr = Directionality.of(context) == TextDirection.ltr;
    final (along, length) = switch (widget.axis) {
      Axis.horizontal => (ltr ? local.dx : box.size.width - local.dx, box.size.width),
      Axis.vertical => (box.size.height - local.dy, box.size.height),
    };
    final span = length - 2 * cap;
    if (span <= 0) return null;
    return ((along - cap) / span).clamp(0.0, 1.0) * _channel.max;
  }

  void _start(Offset global, double cap) {
    setState(() => _dragging = true);
    if (_valueAt(global, cap) case final value?) _change(value);
  }

  void _update(Offset global, double cap) {
    if (!_dragging) return;
    if (_valueAt(global, cap) case final value?) _change(value);
  }

  void _end() {
    if (!_dragging) return;
    setState(() => _dragging = false);
    widget.onChangeEnd?.call(_value);
  }

  void _tap(Offset global, double cap) {
    if (_valueAt(global, cap) case final value?) _commit(value);
  }

  Map<ShortcutActivator, Intent> get _shortcuts {
    final forward = Directionality.of(context) == TextDirection.ltr ? 1 : -1;
    return {
      const SingleActivator(LogicalKeyboardKey.arrowRight): _StepIntent(forward),
      const SingleActivator(LogicalKeyboardKey.arrowLeft): _StepIntent(-forward),
      const SingleActivator(LogicalKeyboardKey.arrowUp): const _StepIntent(1),
      const SingleActivator(LogicalKeyboardKey.arrowDown): const _StepIntent(-1),
      const SingleActivator(LogicalKeyboardKey.pageUp): const _StepIntent(_pageSteps),
      const SingleActivator(LogicalKeyboardKey.pageDown): const _StepIntent(-_pageSteps),
      const SingleActivator(LogicalKeyboardKey.home): const _EdgeIntent(end: false),
      const SingleActivator(LogicalKeyboardKey.end): const _EdgeIntent(end: true),
    };
  }

  @override
  Widget build(BuildContext context) {
    final theme = FlScope.of(context);
    final xs = theme.space.xs;
    final horizontal = widget.axis == Axis.horizontal;
    // color-slider.css: a 20 track (`h-5`) whose caps reach 10 beyond the
    // thumb's travel (`0.625rem`), and a 16 thumb (`size-4`).
    final bar = xs * 5;
    final cap = bar / 2;
    final knob = xs * 4;
    final value = widget.value.channel(_channel, widget.space);
    final increased = math.min(_channel.max, value + _step);
    final decreased = math.max(0.0, value - _step);
    final colors = flColorTrack(widget.value, _channel, widget.space);
    final thumbColor = _enabled ? flColorThumbColor(widget.value, _channel) : theme.colors.control;

    final track = LayoutBuilder(
      key: _track,
      builder: (context, constraints) {
        assert(
          horizontal ? constraints.hasBoundedWidth : constraints.hasBoundedHeight,
          horizontal
              ? 'FlColorSlider needs a bounded width: in a Row, wrap it in an Expanded or a SizedBox.'
              : 'A vertical FlColorSlider needs a bounded height: wrap it in a SizedBox or an Expanded.',
        );
        final length = horizontal ? constraints.maxWidth : constraints.maxHeight;
        final center = cap + (value / _channel.max) * math.max(0.0, length - 2 * cap);
        final thumb = FlFocusRing(
          visible: _focused,
          borderRadius: BorderRadius.circular(knob / 2),
          child: FlColorThumb(color: thumbColor, extent: knob),
        );
        return SizedBox(
          width: horizontal ? length : bar,
          height: horizontal ? bar : length,
          child: Stack(
            clipBehavior: Clip.none,
            children: [
              Positioned.fill(
                child: _Track(
                  colors: colors,
                  axis: widget.axis,
                  cap: cap,
                  checker: _channel == FlColorChannel.alpha,
                  edge: theme.colors.onSurface.withValues(alpha: flColorEdgeOpacity),
                ),
              ),
              if (horizontal)
                PositionedDirectional(start: center - knob / 2, top: (bar - knob) / 2, child: thumb)
              else
                PositionedDirectional(bottom: center - knob / 2, start: (bar - knob) / 2, child: thumb),
            ],
          ),
        );
      },
    );

    final slider = Semantics(
      container: true,
      slider: true,
      enabled: _enabled,
      label: widget.label,
      value: _describe(value),
      increasedValue: _enabled && increased != value ? _describe(increased) : null,
      decreasedValue: _enabled && decreased != value ? _describe(decreased) : null,
      onIncrease: _enabled && increased != value ? () => _commit(increased) : null,
      onDecrease: _enabled && decreased != value ? () => _commit(decreased) : null,
      child: FocusableActionDetector(
        focusNode: _focus,
        enabled: _enabled,
        autofocus: widget.autofocus,
        mouseCursor: _enabled ? SystemMouseCursors.click : MouseCursor.defer,
        shortcuts: _shortcuts,
        actions: {
          _StepIntent: CallbackAction<_StepIntent>(onInvoke: (intent) => _commit(_current + intent.steps * _step)),
          _EdgeIntent: CallbackAction<_EdgeIntent>(onInvoke: (intent) => _commit(intent.end ? _channel.max : 0)),
        },
        onShowFocusHighlight: (visible) => setState(() => _focused = visible),
        child: FlHitTarget(
          child: GestureDetector(
            behavior: HitTestBehavior.opaque,
            excludeFromSemantics: true,
            dragStartBehavior: DragStartBehavior.down,
            onTapUp: _enabled ? (details) => _tap(details.globalPosition, cap) : null,
            onHorizontalDragStart: _enabled && horizontal ? (details) => _start(details.globalPosition, cap) : null,
            onHorizontalDragUpdate: _enabled && horizontal ? (details) => _update(details.globalPosition, cap) : null,
            onHorizontalDragEnd: _enabled && horizontal ? (_) => _end() : null,
            onHorizontalDragCancel: _enabled && horizontal ? _end : null,
            onVerticalDragStart: _enabled && !horizontal ? (details) => _start(details.globalPosition, cap) : null,
            onVerticalDragUpdate: _enabled && !horizontal ? (details) => _update(details.globalPosition, cap) : null,
            onVerticalDragEnd: _enabled && !horizontal ? (_) => _end() : null,
            onVerticalDragCancel: _enabled && !horizontal ? _end : null,
            child: track,
          ),
        ),
      ),
    );

    final body = !widget.showLabel
        ? slider
        : Column(
            mainAxisSize: horizontal ? MainAxisSize.min : MainAxisSize.max,
            crossAxisAlignment: horizontal ? CrossAxisAlignment.stretch : CrossAxisAlignment.center,
            // gap-1 of `color-slider`, gap-2 when vertical.
            spacing: horizontal ? xs : xs * 2,
            children: [
              _header(theme, _describe(value)),
              if (horizontal) slider else Expanded(child: slider),
            ],
          );
    return Opacity(opacity: _enabled ? 1 : theme.size.disabledOpacity, child: body);
  }

  /// The label at the start and the value at the end, in medium `textSm`,
  /// the value in tabular figures. Outside the semantics, which the slider
  /// node already carries.
  Widget _header(FlTheme theme, String value) {
    const role = FlTextRole.textSm;
    final style = theme.typography
        .resolve(role)
        .copyWith(color: theme.colors.onSurface, fontWeight: theme.tokens.typography.weight.medium);
    final strut = theme.typography.strut(role, inheritedFamily: DefaultTextStyle.of(context).style.fontFamily);
    final output = Text(
      value,
      style: style.copyWith(fontFeatures: const [FontFeature.tabularFigures()]),
      strutStyle: strut,
    );
    return ExcludeSemantics(
      child: widget.axis == Axis.vertical
          ? output
          : Row(
              spacing: theme.space.sm,
              children: [
                Expanded(
                  child: Text(widget.label, style: style, strutStyle: strut),
                ),
                output,
              ],
            ),
    );
  }
}

/// The track: a capsule of [colors] whose gradient runs between the middles
/// of its caps, each cap in the end color, over a checkerboard for alpha,
/// with a 1 rule inside its edge.
class _Track extends StatelessWidget {
  const _Track({
    required this.colors,
    required this.axis,
    required this.cap,
    required this.checker,
    required this.edge,
  });

  final List<Color> colors;
  final Axis axis;
  final double cap;
  final bool checker;
  final Color edge;

  @override
  Widget build(BuildContext context) {
    const shape = StadiumBorder();
    return LayoutBuilder(
      builder: (context, constraints) {
        final length = axis == Axis.horizontal ? constraints.maxWidth : constraints.maxHeight;
        final start = length <= 0 ? 0.0 : cap / length;
        final n = colors.length;
        final gradient = LinearGradient(
          begin: axis == Axis.horizontal ? AlignmentDirectional.centerStart : AlignmentDirectional.bottomCenter,
          end: axis == Axis.horizontal ? AlignmentDirectional.centerEnd : AlignmentDirectional.topCenter,
          colors: colors,
          stops: [for (var i = 0; i < n; i++) start + (1 - 2 * start) * i / (n - 1)],
        );
        Widget track = DecoratedBox(
          decoration: ShapeDecoration(gradient: gradient, shape: shape),
          position: DecorationPosition.background,
          child: DecoratedBox(decoration: ShapeDecoration(shape: flColorEdge(shape, edge))),
        );
        if (checker) track = FlColorChecker(shape: shape, child: track);
        return track;
      },
    );
  }
}

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