Fliin
On this page

Components / forms

Color area

Sets two channels of a color at once on a gradient square, saturation and brightness under the chosen hue by default.

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

Import

In a Flutter app where fliin init has run:

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

Examples

Example

A two-dimensional picker: saturation across, brightness down. Drag the thumb, or use the arrow keys.

Color area, example state
import 'package:your_app/ui/fliin/components/color_area/fl_color_area.dart';
import 'package:flutter/widgets.dart';

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

  @override
  State<ColorAreaExample> createState() => _ColorAreaExampleState();
}

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

  @override
  Widget build(BuildContext context) {
    return Center(
      child: FlColorArea(
        value: _color,
        xLabel: 'Saturation',
        yLabel: 'Brightness',
        onChanged: (color) => setState(() => _color = color),
      ),
    );
  }
}

Value

The area starts on the color you pass as value.

Color area, value state
import 'package:your_app/ui/fliin/components/color_area/fl_color_area.dart';
import 'package:flutter/widgets.dart';

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

  @override
  State<ColorAreaValueExample> createState() => _ColorAreaValueExampleState();
}

class _ColorAreaValueExampleState extends State<ColorAreaValueExample> {
  var _color = const HSVColor.fromAHSV(1, 291, 0.45, 0.8);

  @override
  Widget build(BuildContext context) {
    return Center(
      child: FlColorArea(
        value: _color,
        xLabel: 'Saturation',
        yLabel: 'Brightness',
        onChanged: (color) => setState(() => _color = color),
      ),
    );
  }
}

Dots

Any two channels, here hue and saturation, with showDots drawing a dot grid over the area.

Color area, dots state
import 'package:your_app/ui/fliin/components/color_area/fl_color_area.dart';
import 'package:your_app/ui/fliin/components/color_swatch/fl_color_channel.dart';
import 'package:flutter/widgets.dart';

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

  @override
  State<ColorAreaDotsExample> createState() => _ColorAreaDotsExampleState();
}

class _ColorAreaDotsExampleState extends State<ColorAreaDotsExample> {
  var _color = const HSVColor.fromAHSV(1, 200, 1, 0.9);

  @override
  Widget build(BuildContext context) {
    return Center(
      child: FlColorArea(
        value: _color,
          xChannel: FlColorChannel.hue,
          yChannel: FlColorChannel.saturation,
          showDots: true,
        xLabel: 'Hue',
        yLabel: 'Saturation',
        onChanged: (color) => setState(() => _color = color),
      ),
    );
  }
}

Disabled

onChanged: null disables the area.

Color area, disabled state
import 'package:your_app/ui/fliin/components/color_area/fl_color_area.dart';
import 'package:flutter/widgets.dart';

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

  @override
  State<ColorAreaDisabledExample> createState() => _ColorAreaDisabledExampleState();
}

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

  @override
  Widget build(BuildContext context) {
    return Center(
      child: FlColorArea(
        value: _color,
        xLabel: 'Saturation',
        yLabel: 'Brightness',
        onChanged: null,
      ),
    );
  }
}

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;
    return ColoredBox(
      color: theme.colors.surface,
      child: SingleChildScrollView(
        padding: EdgeInsetsDirectional.all(theme.space.lg),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          spacing: theme.space.lg,
          children: [
            FlColorArea(
              value: const HSVColor.fromAHSV(1, 291, 0.45, 0.8),
              xLabel: tr('Saturation', 'التشبع'),
              yLabel: tr('Brightness', 'السطوع'),
              onChanged: (_) {},
            ),
            Row(
              spacing: theme.space.lg,
              children: [
                Expanded(
                  child: FlColorArea(
                    value: const HSVColor.fromAHSV(1, 200, 1, 0.9),
                    xChannel: FlColorChannel.hue,
                    yChannel: FlColorChannel.saturation,
                    showDots: true,
                    xLabel: tr('Hue', 'الصبغة'),
                    yLabel: tr('Saturation', 'التشبع'),
                    onChanged: (_) {},
                  ),
                ),
                Expanded(
                  child: FlColorArea(
                    value: const HSVColor.fromAHSV(1, 140, 0.6, 0.7),
                    xLabel: tr('Saturation', 'التشبع'),
                    yLabel: tr('Brightness', 'السطوع'),
                    onChanged: null,
                  ),
                ),
              ],
            ),
          ],
        ),
      ),
    );
  },
);

API

FlColorArea: class extends StatefulWidget
FlColorArea.new: const new({super.key, required HSVColor value, required ValueChanged<HSVColor>? onChanged, required String xLabel, required String yLabel, ValueChanged<HSVColor>? onChangeEnd, FlColorChannel xChannel, FlColorChannel yChannel, bool showDots, bool expand, bool autofocus})
FlColorArea.value: get HSVColor
FlColorArea.onChanged: get ValueChanged<HSVColor>?
FlColorArea.onChangeEnd: get ValueChanged<HSVColor>?
FlColorArea.xLabel: get String
FlColorArea.yLabel: get String
FlColorArea.xChannel: get FlColorChannel
FlColorArea.yChannel: get FlColorChannel
FlColorArea.showDots: get bool
FlColorArea.expand: get bool
FlColorArea.autofocus: get bool
FlColorArea.createState: State<FlColorArea>()

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-area compares your copy to them.

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

Use it to

  • pick saturation and brightness of a colour on a square
  • adjust two colour channels at once on a gradient square
  • choose hue and saturation in one colour square
  • show a dot grid over the colour area
  • move the colour with the keyboard arrows
Not for
  • a single channel, such as the hue: use a color slider (color-slider)
  • one color among a few shades: use a color swatch picker (color-swatch-picker)
  • a full choice in a panel: use a color picker (color-picker)
States
  • Value
  • Dots
  • Disabled

Source

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

color_area/fl_color_area.dart

// Modified from the ColorArea 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 percent (one degree of hue); Page, ten times as much.
const int _pageSteps = 10;

/// The dots of `color-area--show-dots`: 1 in radius every 8, in white at
/// 20 %.
const double _dotOpacity = 0.2;

class _MoveIntent extends Intent {
  const _MoveIntent(this.dx, this.dy);

  final int dx;
  final int dy;
}

/// A two-dimensional color area: [xChannel] grows
/// toward the end, [yChannel] upward, over a square painted with the colors
/// they reach, computed from [value]; a 16 thumb ringed with white shows the
/// picked color and grows to 20 while dragged. By default, saturation and
/// brightness, under the hue of [value]: the square of a color picker.
///
/// The color is a value of the app, held as an [HSVColor]; none of the
/// area's tints comes from the theme. The area does not keep it: the parent
/// gives back in [value] what [onChanged] receives.
///
/// The screen reader reads two sliders, one per channel, named by
/// [xLabel] and [yLabel]. On the keyboard, a single Tab stop: the
/// horizontal arrows move [xChannel], the vertical ones [yChannel], by one
/// percent, ten times as much with Shift or Page.
class FlColorArea extends StatefulWidget {
  /// An area over [xChannel] and [yChannel] of [value].
  const FlColorArea({
    super.key,
    required this.value,
    required this.onChanged,
    required this.xLabel,
    required this.yLabel,
    this.onChangeEnd,
    this.xChannel = FlColorChannel.saturation,
    this.yChannel = FlColorChannel.brightness,
    this.showDots = false,
    this.expand = false,
    this.autofocus = false,
  }) : assert(xChannel != yChannel, 'two different channels'),
       assert(
         xChannel != FlColorChannel.lightness &&
             yChannel != FlColorChannel.lightness &&
             xChannel != FlColorChannel.alpha &&
             yChannel != FlColorChannel.alpha,
         'the area crosses two channels of hsb: hue, saturation, brightness',
       );

  /// The color shown; the area moves its two channels only.
  final HSVColor value;

  /// Receives the color with its new channels while dragging, on tap and
  /// from the keyboard; null, the area is disabled and fades.
  final ValueChanged<HSVColor>? onChanged;

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

  /// The name of the horizontal channel for the screen reader
  /// ("Saturation").
  final String xLabel;

  /// The name of the vertical channel for the screen reader ("Brightness").
  final String yLabel;

  /// The horizontal channel, saturation by default.
  final FlColorChannel xChannel;

  /// The vertical channel, brightness by default.
  final FlColorChannel yChannel;

  /// True: a grid of light dots over the colors (`showDots`).
  final bool showDots;

  /// False (the default): at most 224 per side (`max-w-56`); true, the
  /// available width, as in a color picker (`max-w-full`).
  final bool expand;

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

  @override
  State<FlColorArea> createState() => _FlColorAreaState();
}

class _FlColorAreaState extends State<FlColorArea> with SingleTickerProviderStateMixin {
  final GlobalKey _box = GlobalKey();
  final FocusNode _focus = FocusNode(debugLabel: 'FlColorArea');

  /// 0 at rest, 1 dragged: the thumb grows from 16 to 20 over `short3`.
  late final AnimationController _grow = AnimationController(vsync: this);
  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;

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

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

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

  double _read(FlColorChannel channel) => _value.channel(channel, FlColorSpace.hsb);

  double _stepOf(FlColorChannel channel) => channel == FlColorChannel.hue ? 1 : 0.01;

  HSVColor _with(double x, double y) =>
      _value.withChannel(widget.xChannel, FlColorSpace.hsb, x).withChannel(widget.yChannel, FlColorSpace.hsb, y);

  void _change(HSVColor next) {
    if (next == _value) return;
    _sent = next;
    widget.onChanged?.call(next);
  }

  void _commit(HSVColor next) {
    _change(next);
    widget.onChangeEnd?.call(next);
  }

  HSVColor? _at(Offset global) {
    final box = _box.currentContext?.findRenderObject();
    if (box is! RenderBox || !box.hasSize || box.size.isEmpty) return null;
    final local = box.globalToLocal(global);
    final ltr = Directionality.of(context) == TextDirection.ltr;
    final fx = ((ltr ? local.dx : box.size.width - local.dx) / box.size.width).clamp(0.0, 1.0);
    final fy = (1 - local.dy / box.size.height).clamp(0.0, 1.0);
    return _with(fx * widget.xChannel.max, fy * widget.yChannel.max);
  }

  void _setDragging(bool dragging) {
    if (dragging == _dragging) return;
    setState(() => _dragging = dragging);
    final motion = FlMotion.of(context);
    if (motion.reduced) {
      _grow.value = dragging ? 1 : 0;
    } else {
      _grow.animateTo(dragging ? 1 : 0, duration: motion.tokens.short3, curve: motion.tokens.easeOut);
    }
  }

  void _start(Offset global) {
    _setDragging(true);
    if (_at(global) case final next?) _change(next);
  }

  void _update(Offset global) {
    if (!_dragging) return;
    if (_at(global) case final next?) _change(next);
  }

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

  void _move(int dx, int dy) => _commit(
    _with(
      _read(widget.xChannel) + dx * _stepOf(widget.xChannel),
      _read(widget.yChannel) + dy * _stepOf(widget.yChannel),
    ),
  );

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

  /// The slider node of one channel, adjusted by one step.
  Widget _axisNode(FlColorChannel channel, String label, Widget child) {
    final value = widget.value.channel(channel, FlColorSpace.hsb);
    final step = _stepOf(channel);
    final up = math.min(channel.max, value + step);
    final down = math.max(0.0, value - step);
    HSVColor set(double to) => _value.withChannel(channel, FlColorSpace.hsb, to);
    final canUp = _enabled && up != value;
    final canDown = _enabled && down != value;
    return Semantics(
      container: true,
      slider: true,
      enabled: _enabled,
      label: label,
      value: flColorChannelLabel(channel, value),
      increasedValue: canUp ? flColorChannelLabel(channel, up) : null,
      decreasedValue: canDown ? flColorChannelLabel(channel, down) : null,
      onIncrease: canUp ? () => _commit(set(up)) : null,
      onDecrease: canDown ? () => _commit(set(down)) : null,
      child: child,
    );
  }

  @override
  Widget build(BuildContext context) {
    final theme = FlScope.of(context);
    final xs = theme.space.xs;
    // color-area.css: `max-w-56`, square, `rounded-2xl`; a 16 thumb that
    // grows to 20 while dragged.
    final maxSide = xs * 56;
    final knob = xs * 4;
    final grown = xs * 5;
    final radius = theme.shape.radiusXxl;
    final shape = RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(radius)));
    final x = widget.value.channel(widget.xChannel, FlColorSpace.hsb) / widget.xChannel.max;
    final y = widget.value.channel(widget.yChannel, FlColorSpace.hsb) / widget.yChannel.max;
    final thumbColor = _enabled ? widget.value.withAlpha(1).toColor() : theme.colors.control;

    final area = LayoutBuilder(
      builder: (context, constraints) {
        // Without a bounded width, the area keeps its 224.
        final side = !constraints.hasBoundedWidth
            ? maxSide
            : widget.expand
            ? constraints.maxWidth
            : math.min(maxSide, constraints.maxWidth);
        // Square inside a wider box, held at its start.
        return Align(
          alignment: AlignmentDirectional.topStart,
          widthFactor: 1,
          heightFactor: 1,
          child: SizedBox.square(
            key: _box,
            dimension: side,
            child: Stack(
              clipBehavior: Clip.none,
              children: [
                Positioned.fill(
                  child: CustomPaint(
                    painter: _AreaPainter(
                      value: widget.value.withAlpha(1),
                      xChannel: widget.xChannel,
                      yChannel: widget.yChannel,
                      shape: shape,
                      textDirection: Directionality.of(context),
                      dots: widget.showDots ? theme.colors.switchThumb.withValues(alpha: _dotOpacity) : null,
                      dotPitch: theme.space.sm,
                    ),
                    child: DecoratedBox(
                      decoration: ShapeDecoration(
                        shape: flColorEdge(shape, theme.colors.onSurface.withValues(alpha: flColorEdgeOpacity)),
                      ),
                    ),
                  ),
                ),
                AnimatedBuilder(
                  animation: _grow,
                  builder: (context, _) {
                    final extent = knob + (grown - knob) * _grow.value;
                    return PositionedDirectional(
                      start: x * side - extent / 2,
                      top: (1 - y) * side - extent / 2,
                      child: FlFocusRing(
                        visible: _focused,
                        borderRadius: BorderRadius.circular(extent / 2),
                        child: FlColorThumb(color: thumbColor, extent: extent, lifted: false),
                      ),
                    );
                  },
                ),
              ],
            ),
          ),
        );
      },
    );

    return Opacity(
      opacity: _enabled ? 1 : theme.size.disabledOpacity,
      child: FocusableActionDetector(
        focusNode: _focus,
        enabled: _enabled,
        autofocus: widget.autofocus,
        mouseCursor: _enabled ? SystemMouseCursors.click : MouseCursor.defer,
        shortcuts: _shortcuts,
        actions: {_MoveIntent: CallbackAction<_MoveIntent>(onInvoke: (intent) => _move(intent.dx, intent.dy))},
        onShowFocusHighlight: (visible) => setState(() => _focused = visible),
        child: Semantics(
          explicitChildNodes: true,
          child: _axisNode(
            widget.xChannel,
            widget.xLabel,
            _axisNode(
              widget.yChannel,
              widget.yLabel,
              GestureDetector(
                behavior: HitTestBehavior.opaque,
                excludeFromSemantics: true,
                dragStartBehavior: DragStartBehavior.down,
                onTapUp: _enabled ? (details) => _commit(_at(details.globalPosition) ?? widget.value) : null,
                onPanStart: _enabled ? (details) => _start(details.globalPosition) : null,
                onPanUpdate: _enabled ? (details) => _update(details.globalPosition) : null,
                onPanEnd: _enabled ? (_) => _end() : null,
                onPanCancel: _enabled ? _end : null,
                child: area,
              ),
            ),
          ),
        ),
      ),
    );
  }
}

/// The area's colors, one vertical strip per logical point: each strip is a
/// gradient along [yChannel] at the [xChannel] value of its middle. Exact
/// stops (six sixths for the hue, two ends otherwise), since each channel of
/// hsb is linear in RGB between them.
class _AreaPainter extends CustomPainter {
  const _AreaPainter({
    required this.value,
    required this.xChannel,
    required this.yChannel,
    required this.shape,
    required this.textDirection,
    required this.dots,
    required this.dotPitch,
  });

  final HSVColor value;
  final FlColorChannel xChannel;
  final FlColorChannel yChannel;
  final ShapeBorder shape;
  final TextDirection textDirection;
  final Color? dots;
  final double dotPitch;

  @override
  void paint(Canvas canvas, Size size) {
    if (size.isEmpty) return;
    final rect = Offset.zero & size;
    canvas
      ..save()
      ..clipPath(shape.getOuterPath(rect, textDirection: textDirection));
    final strips = size.width.ceil();
    final width = size.width / strips;
    final stops = yChannel == FlColorChannel.hue ? 7 : 2;
    final ltr = textDirection == TextDirection.ltr;
    final paint = Paint()..isAntiAlias = false;
    for (var i = 0; i < strips; i++) {
      final fx = (i + 0.5) / strips;
      final column = value.withChannel(xChannel, FlColorSpace.hsb, fx * xChannel.max);
      final left = ltr ? i * width : size.width - (i + 1) * width;
      // Each strip overlaps the next by a fraction, so that no seam shows.
      final strip = Rect.fromLTWH(left, 0, width + 0.5, size.height);
      paint.shader = LinearGradient(
        begin: AlignmentDirectional.bottomCenter,
        end: AlignmentDirectional.topCenter,
        colors: [
          for (var k = 0; k < stops; k++)
            column.withChannel(yChannel, FlColorSpace.hsb, yChannel.max * k / (stops - 1)).toColor(),
        ],
      ).createShader(strip, textDirection: textDirection);
      canvas.drawRect(strip, paint);
    }
    if (dots case final color?) {
      final dot = Paint()..color = color;
      for (var y = dotPitch / 2; y < size.height; y += dotPitch) {
        for (var x = dotPitch / 2; x < size.width; x += dotPitch) {
          canvas.drawCircle(Offset(x, y), 1, dot);
        }
      }
    }
    canvas.restore();
  }

  @override
  bool shouldRepaint(_AreaPainter oldDelegate) =>
      value != oldDelegate.value ||
      xChannel != oldDelegate.xChannel ||
      yChannel != oldDelegate.yChannel ||
      shape != oldDelegate.shape ||
      textDirection != oldDelegate.textDirection ||
      dots != oldDelegate.dots ||
      dotPitch != oldDelegate.dotPitch;
}

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