Fliin
On this page

Components / data-display

Avatar group

Groups the people of a conversation or a project into a row of overlapping avatars, with a +N counter past a maximum.

16 of 16 variants pass
Avatar group, basic state
Platform
Brightness
Text scale
Direction

Import

In a Flutter app where fliin init has run:

fliin add avatar-groupAlso installs: core, avatar

Examples

Basic

Members of a team, overlapping, each an FlAvatar whose initials stand in while its photo loads. The photos come from your app.

Avatar group, basic state
import 'package:your_app/ui/fliin/components/avatar/fl_avatar.dart';
import 'package:your_app/ui/fliin/components/avatar_group/fl_avatar_group.dart';
import 'package:flutter/widgets.dart';

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

  @override
  Widget build(BuildContext context) {
    return const FlAvatarGroup(avatars: _people);
  }
}

const _people = [
  FlAvatar(image: AssetImage('images/people/2.jpg'), initials: 'JD', semanticLabel: 'John Doe'),
  FlAvatar(image: AssetImage('images/people/1.jpg'), initials: 'KW', semanticLabel: 'Kate Wilson'),
  FlAvatar(image: AssetImage('images/people/4.jpg'), initials: 'MB', semanticLabel: 'Michael Brown'),
  FlAvatar(image: AssetImage('images/people/3.jpg'), initials: 'EC', semanticLabel: 'Emily Chen'),
  FlAvatar(image: AssetImage('images/people/6.jpg'), initials: 'SL', semanticLabel: 'Sarah Lee'),
];

Sizes

The group in every avatar size but xl, which is a profile's head, never a group's.

Avatar group, sizes state
import 'package:your_app/ui/fliin/components/avatar/fl_avatar.dart';
import 'package:your_app/ui/fliin/components/avatar_group/fl_avatar_group.dart';
import 'package:flutter/widgets.dart';
import 'package:your_app/ui/fliin/core/fliin.dart';

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

  @override
  Widget build(BuildContext context) {
    return Column(
      mainAxisSize: MainAxisSize.min,
      spacing: FlScope.of(context).space.md,
      children: [
        for (final size in FlAvatarSize.values.where((size) => size != FlAvatarSize.xl))
          FlAvatarGroup(avatars: _people, size: size),
      ],
    );
  }
}

const _people = [
  FlAvatar(image: AssetImage('images/people/2.jpg'), initials: 'JD', semanticLabel: 'John Doe'),
  FlAvatar(image: AssetImage('images/people/1.jpg'), initials: 'KW', semanticLabel: 'Kate Wilson'),
  FlAvatar(image: AssetImage('images/people/4.jpg'), initials: 'MB', semanticLabel: 'Michael Brown'),
  FlAvatar(image: AssetImage('images/people/3.jpg'), initials: 'EC', semanticLabel: 'Emily Chen'),
  FlAvatar(image: AssetImage('images/people/6.jpg'), initials: 'SL', semanticLabel: 'Sarah Lee'),
];

Grid

grid lays the avatars side by side without overlap and wraps them onto the next line; max still caps them, the rest in the counter.

Avatar group, grid state
import 'package:your_app/ui/fliin/components/avatar/fl_avatar.dart';
import 'package:your_app/ui/fliin/components/avatar_group/fl_avatar_group.dart';
import 'package:flutter/widgets.dart';

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

  @override
  Widget build(BuildContext context) {
    return FlAvatarGroup(avatars: _people, max: 6, grid: true, countLabel: (n) => 'and $n others');
  }
}

const _people = [
  FlAvatar(image: AssetImage('images/people/2.jpg'), initials: 'JD', semanticLabel: 'John Doe'),
  FlAvatar(image: AssetImage('images/people/1.jpg'), initials: 'KW', semanticLabel: 'Kate Wilson'),
  FlAvatar(image: AssetImage('images/people/4.jpg'), initials: 'MB', semanticLabel: 'Michael Brown'),
  FlAvatar(image: AssetImage('images/people/3.jpg'), initials: 'EC', semanticLabel: 'Emily Chen'),
  FlAvatar(image: AssetImage('images/people/6.jpg'), initials: 'SL', semanticLabel: 'Sarah Lee'),
  FlAvatar(image: AssetImage('images/people/5.jpg'), initials: 'OD', semanticLabel: 'Olivia Davis'),
  FlAvatar(initials: 'TN', semanticLabel: 'Tom Nguyen', color: FlAvatarColor.success),
  FlAvatar(initials: 'AR', semanticLabel: 'Ana Ruiz', color: FlAvatarColor.warning),
];

Stacked

A small group, as on a card or a list row: size: FlAvatarSize.sm.

Avatar group, stacked state
import 'package:your_app/ui/fliin/components/avatar/fl_avatar.dart';
import 'package:your_app/ui/fliin/components/avatar_group/fl_avatar_group.dart';
import 'package:flutter/widgets.dart';

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

  @override
  Widget build(BuildContext context) {
    return const FlAvatarGroup(avatars: _people, size: FlAvatarSize.sm);
  }
}

const _people = [
  FlAvatar(image: AssetImage('images/people/2.jpg'), initials: 'JD', semanticLabel: 'John Doe'),
  FlAvatar(image: AssetImage('images/people/1.jpg'), initials: 'KW', semanticLabel: 'Kate Wilson'),
  FlAvatar(image: AssetImage('images/people/4.jpg'), initials: 'MB', semanticLabel: 'Michael Brown'),
  FlAvatar(image: AssetImage('images/people/3.jpg'), initials: 'EC', semanticLabel: 'Emily Chen'),
  FlAvatar(image: AssetImage('images/people/6.jpg'), initials: 'SL', semanticLabel: 'Sarah Lee'),
];

Mixed

Photos, initials in a color and a silhouette side by side: each member is any FlAvatar.

Avatar group, mixed state
import 'package:your_app/ui/fliin/components/avatar/fl_avatar.dart';
import 'package:your_app/ui/fliin/components/avatar_group/fl_avatar_group.dart';
import 'package:flutter/widgets.dart';

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

  @override
  Widget build(BuildContext context) {
    return const FlAvatarGroup(
      avatars: [
        FlAvatar(image: AssetImage('images/people/2.jpg'), initials: 'JD', semanticLabel: 'John Doe'),
        FlAvatar(initials: 'KW', semanticLabel: 'Kate Wilson', color: FlAvatarColor.success),
        FlAvatar(image: AssetImage('images/people/3.jpg'), initials: 'EC', semanticLabel: 'Emily Chen'),
        FlAvatar(initials: 'MB', semanticLabel: 'Michael Brown', color: FlAvatarColor.warning),
        FlAvatar(semanticLabel: 'Guest', variant: FlAvatarVariant.soft),
      ],
    );
  }
}

Overflow

More members than room: max shows three, total counts the whole team, and countLabel names the rest for the screen reader.

Avatar group, overflow state
import 'package:your_app/ui/fliin/components/avatar/fl_avatar.dart';
import 'package:your_app/ui/fliin/components/avatar_group/fl_avatar_group.dart';
import 'package:flutter/widgets.dart';

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

  @override
  Widget build(BuildContext context) {
    return FlAvatarGroup(avatars: _people, max: 3, total: 12, countLabel: (n) => '$n more');
  }
}

const _people = [
  FlAvatar(image: AssetImage('images/people/2.jpg'), initials: 'JD', semanticLabel: 'John Doe'),
  FlAvatar(image: AssetImage('images/people/1.jpg'), initials: 'KW', semanticLabel: 'Kate Wilson'),
  FlAvatar(image: AssetImage('images/people/4.jpg'), initials: 'MB', semanticLabel: 'Michael Brown'),
  FlAvatar(image: AssetImage('images/people/3.jpg'), initials: 'EC', semanticLabel: 'Emily Chen'),
  FlAvatar(image: AssetImage('images/people/6.jpg'), initials: 'SL', semanticLabel: 'Sarah Lee'),
];

Ring

FlAvatarGroupOverlap.ring separates each avatar from the next with a ring of the background, instead of a plain overlap.

Avatar group, ring state
import 'package:your_app/ui/fliin/components/avatar/fl_avatar.dart';
import 'package:your_app/ui/fliin/components/avatar_group/fl_avatar_group.dart';
import 'package:flutter/widgets.dart';

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

  @override
  Widget build(BuildContext context) {
    return FlAvatarGroup(
      avatars: _people,
      max: 3,
      overlap: FlAvatarGroupOverlap.ring,
      countLabel: (n) => '$n more',
    );
  }
}

const _people = [
  FlAvatar(image: AssetImage('images/people/2.jpg'), initials: 'JD', semanticLabel: 'John Doe'),
  FlAvatar(image: AssetImage('images/people/1.jpg'), initials: 'KW', semanticLabel: 'Kate Wilson'),
  FlAvatar(image: AssetImage('images/people/4.jpg'), initials: 'MB', semanticLabel: 'Michael Brown'),
  FlAvatar(image: AssetImage('images/people/3.jpg'), initials: 'EC', semanticLabel: 'Emily Chen'),
  FlAvatar(image: AssetImage('images/people/6.jpg'), initials: 'SL', semanticLabel: 'Sarah Lee'),
];

Usage

The code the reference images render:

final example = 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 s in FlAvatarSize.values.where((s) => s != FlAvatarSize.xl))
          Align(
            alignment: AlignmentDirectional.centerStart,
            child: FlAvatarGroup(
              avatars: [
                FlAvatar(initials: 'JD', semanticLabel: 'Jane Doe'),
                FlAvatar(
                  initials: 'KW',
                  semanticLabel: 'Kate Wilson',
                  color: FlAvatarColor.success,
                ),
                FlAvatar(
                  initials: 'EC',
                  semanticLabel: 'Emily Chen',
                  color: FlAvatarColor.warning,
                ),
                FlAvatar(
                  semanticLabel: 'Guest',
                  variant: FlAvatarVariant.soft,
                  color: FlAvatarColor.danger,
                ),
              ],
              max: 2,
              total: 8,
              size: s,
              countLabel: (n) => 'and $n more',
            ),
          ),
        Align(
          alignment: AlignmentDirectional.centerStart,
          child: FlAvatarGroup(
            avatars: [
              FlAvatar(initials: 'JD', semanticLabel: 'Jane Doe'),
              FlAvatar(
                initials: 'KW',
                semanticLabel: 'Kate Wilson',
                color: FlAvatarColor.success,
              ),
              FlAvatar(
                initials: 'EC',
                semanticLabel: 'Emily Chen',
                color: FlAvatarColor.warning,
              ),
              FlAvatar(
                semanticLabel: 'Guest',
                variant: FlAvatarVariant.soft,
                color: FlAvatarColor.danger,
              ),
            ],
            max: 3,
            overlap: FlAvatarGroupOverlap.ring,
            countLabel: (n) => 'and $n more',
          ),
        ),
        Align(
          alignment: AlignmentDirectional.centerStart,
          child: FlAvatarGroup(
            avatars: [
              FlAvatar(initials: 'JD', semanticLabel: 'Jane Doe'),
              FlAvatar(
                initials: 'KW',
                semanticLabel: 'Kate Wilson',
                color: FlAvatarColor.success,
              ),
              FlAvatar(
                initials: 'EC',
                semanticLabel: 'Emily Chen',
                color: FlAvatarColor.warning,
              ),
              FlAvatar(
                semanticLabel: 'Guest',
                variant: FlAvatarVariant.soft,
                color: FlAvatarColor.danger,
              ),
            ],
            size: FlAvatarSize.sm,
          ),
        ),
      ],
    ),
  ),
);

API

FlAvatarGroupOverlap: enum {clip, ring}
FlAvatarGroupOverlap.clip: static final FlAvatarGroupOverlap
FlAvatarGroupOverlap.ring: static final FlAvatarGroupOverlap
FlAvatarGroup: class extends StatelessWidget
FlAvatarGroup.new: const new({super.key, required List<FlAvatar> avatars, int? max, int? total, FlAvatarSize size, FlAvatarGroupOverlap overlap, Color? ringColor, String Function(int hidden)? countLabel, bool grid})
FlAvatarGroup.avatars: get List<FlAvatar>
FlAvatarGroup.max: get int?
FlAvatarGroup.total: get int?
FlAvatarGroup.size: get FlAvatarSize
FlAvatarGroup.overlap: get FlAvatarGroupOverlap
FlAvatarGroup.ringColor: get Color?
FlAvatarGroup.countLabel: get String Function(int hidden)?
FlAvatarGroup.grid: get bool
FlAvatarGroup.build: Widget(BuildContext context)

Reference renders

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

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

Use it to

  • show project members as overlapping avatars
  • show who is in a group chat
  • show how many more people follow a thread
  • present event guests in a compact row
  • show the people assigned to a task on a card
  • show stacked profile pictures of members with a count of the others
Not for
  • a single person: use an avatar (avatar)
  • an unread count on an icon: use a badge (badge)
States
  • Stacked
  • Overflow
  • Ring

Source

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

avatar_group/fl_avatar_group.dart

// Modified from the AvatarGroup of HeroUI React v3, Copyright 2020 Next UI
// Inc., licensed under the MIT license below and under Apache 2.0:
// https://www.apache.org/licenses/LICENSE-2.0
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to
// deal in the Software without restriction, including without limitation the
// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
// sell copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
// IN THE SOFTWARE.
//
// Also modified from the Avatar of HeroUI Native 1.0.10, Copyright 2025 NextUI
// Inc., licensed under Apache 2.0.
import 'package:flutter/widgets.dart';
import 'package:fliin_ui/components/avatar/fl_avatar.dart';
import 'package:fliin_ui/core/fliin.dart';

/// How a [FlAvatarGroup] separates an avatar from the one it overlaps.
enum FlAvatarGroupOverlap {
  /// A transparent crescent cut out of the overlapped avatar: the
  /// background, whatever it is, shows through. The default.
  clip,

  /// A solid seam in the background color around each avatar, which
  /// overlaps the previous one by 8 rather than 12.
  ring,
}

/// Overlapping avatars: the participants
/// of a conversation or a project. Beyond [max], a neutral `+N` counter, at
/// the group's size, takes the place of the rest.
///
/// The screen reader reads the group as a single node: the name of each
/// displayed avatar, then [countLabel].
class FlAvatarGroup extends StatelessWidget {
  /// An `md` group of [avatars], cut as crescents.
  const FlAvatarGroup({
    super.key,
    required this.avatars,
    this.max,
    this.total,
    this.size = FlAvatarSize.md,
    this.overlap = FlAvatarGroupOverlap.clip,
    this.ringColor,
    this.countLabel,
    this.grid = false,
  }) : assert(max == null || max >= 0, 'max is non-negative');

  /// The avatars, from the first placed to the last. Their size yields to [size].
  final List<FlAvatar> avatars;

  /// The maximum number of avatars shown; when null, all.
  final int? max;

  /// The number of participants when [avatars] gives only some of them (the
  /// first of a half-loaded list): the counter shows the rest. When null:
  /// the length of [avatars].
  final int? total;

  /// The size of each avatar and of the counter.
  final FlAvatarSize size;

  /// The transparent crescent or the solid ring.
  final FlAvatarGroupOverlap overlap;

  /// The seam's color, that of the background under the group. When null:
  /// `surface`, the page background. No effect on the crescent.
  final Color? ringColor;

  /// What the screen reader reads for the counter ("and 2 others"). When
  /// null: the displayed text, `+2`.
  final String Function(int hidden)? countLabel;

  /// True: the avatars sit side by side without overlapping, `space.md`
  /// apart, and wrap onto the next line when the width runs out (the
  /// members of a team, the guests of an event). [overlap]
  /// and [ringColor] then have no effect.
  final bool grid;

  @override
  Widget build(BuildContext context) {
    final max = this.max;
    final shown = max == null || max >= avatars.length ? avatars : avatars.sublist(0, max);
    final hidden = (total ?? avatars.length) - shown.length;
    final members = [
      for (final a in shown)
        FlAvatar(
          key: a.key,
          image: a.image,
          initials: a.initials,
          icon: a.icon,
          semanticLabel: a.semanticLabel,
          size: size,
          variant: a.variant,
          color: a.color,
          fallbackDelay: a.fallbackDelay,
        ),
      if (hidden > 0)
        FlAvatar(
          icon: _Count('+$hidden', size),
          semanticLabel: countLabel?.call(hidden) ?? '+$hidden',
          size: size,
          color: FlAvatarColor.neutral,
        ),
    ];
    if (grid) {
      final gap = FlScope.of(context).space.md;
      return MergeSemantics(
        child: Wrap(spacing: gap, runSpacing: gap, children: members),
      );
    }
    final ring = ringColor ?? FlScope.of(context).colors.surface;
    return MergeSemantics(
      child: Row(
        mainAxisSize: MainAxisSize.min,
        children: [
          for (final (i, member) in members.indexed)
            _Seat(
              first: i == 0,
              cut: overlap == FlAvatarGroupOverlap.clip && i < members.length - 1,
              ring: overlap == FlAvatarGroupOverlap.ring ? ring : null,
              child: member,
            ),
        ],
      ),
    );
  }
}

/// The counter, in the style of [FlAvatar]'s initials (medium weight, text
/// capped at 1.4 times), shrunk to fit in the avatar when it is long
/// (`+128` at scale 2.0).
class _Count extends StatelessWidget {
  const _Count(this.text, this.size);

  final String text;
  final FlAvatarSize size;

  @override
  Widget build(BuildContext context) {
    final theme = FlScope.of(context);
    final scale = theme.typography;
    final role = switch (size) {
      FlAvatarSize.sm => FlTextRole.textXs,
      FlAvatarSize.md => FlTextRole.textSm,
      FlAvatarSize.lg => FlTextRole.textBase,
      FlAvatarSize.xl => FlTextRole.heading2,
    };
    return MediaQuery.withClampedTextScaling(
      maxScaleFactor: _maxTextScale,
      child: FittedBox(
        fit: BoxFit.scaleDown,
        child: Text(
          text,
          maxLines: 1,
          textAlign: TextAlign.center,
          style: scale
              .resolve(role)
              .copyWith(color: IconTheme.of(context).color, fontWeight: theme.tokens.typography.weight.medium),
          strutStyle: scale.strut(role, inheritedFamily: DefaultTextStyle.of(context).style.fontFamily),
        ),
      ),
    );
  }
}

/// The largest text scale the initials follow, that of [FlAvatar].
const double _maxTextScale = 1.4;

/// The group's overlap and seam, and the shape of an avatar.
typedef _Geometry = ({double side, double overlap, double seam, double radius});

_Geometry _geometry(BuildContext context, FlAvatarSize size, {required bool ringed}) {
  final theme = FlScope.of(context);
  final xs = theme.space.xs;
  return (
    // The sides of FlAvatar.
    side: switch (size) {
      FlAvatarSize.sm => xs * 10,
      FlAvatarSize.md => xs * 12,
      FlAvatarSize.lg => xs * 16,
      FlAvatarSize.xl => xs * 22,
    },
    // 12, the overlap of Izydesk's avatar group; 8 with the ring.
    overlap: ringed ? xs * 2 : xs * 3,
    // --avatar-group-seam: 2px, two strokes.
    seam: theme.size.border * 2,
    radius: theme.shape.radiusButtonLarge,
  );
}

/// The shape of [FlAvatar] on [rect], grown by [grow] all around.
Path _avatarShape(Rect rect, double radius, double grow) =>
    FlContinuousBorder(radius: radius + grow).getOuterPath(rect.inflate(grow));

/// The place of an avatar in the row: it tucks under the previous one,
/// except the first, is cut as a crescent where the next one overlaps it
/// ([cut]), or is circled by the seam and the ring of [ring].
class _Seat extends StatelessWidget {
  const _Seat({required this.first, required this.cut, required this.ring, required this.child});

  final bool first;
  final bool cut;
  final Color? ring;
  final FlAvatar child;

  @override
  Widget build(BuildContext context) {
    final ring = this.ring;
    final g = _geometry(context, child.size, ringed: ring != null);
    Widget seat = child;
    if (ring != null) {
      // box-shadow: 0 0 0 2px var(--background): the avatar's shape, solid,
      // under it, grown by the seam.
      seat = CustomPaint(painter: _Ring(ring, g), child: seat);
    }
    if (cut) {
      seat = ClipPath(clipper: _Crescent(Directionality.of(context), g), child: seat);
    }
    if (first) return seat;
    return SizedBox(
      width: g.side - g.overlap,
      height: g.side,
      child: OverflowBox(alignment: AlignmentDirectional.centerEnd, minWidth: g.side, maxWidth: g.side, child: seat),
    );
  }
}

/// `mask-image: radial-gradient(…)`: the shape of the next avatar, grown by
/// the seam, removed from this one, on the end side.
class _Crescent extends CustomClipper<Path> {
  _Crescent(this.direction, this.geometry);

  final TextDirection direction;
  final _Geometry geometry;

  @override
  Path getClip(Size size) {
    final reach = geometry.side - geometry.overlap;
    final next = (Offset.zero & size).shift(Offset(direction == TextDirection.rtl ? -reach : reach, 0));
    return Path()
      ..fillType = PathFillType.evenOdd
      ..addRect(Offset.zero & size)
      ..addPath(_avatarShape(next, geometry.radius, geometry.seam), Offset.zero);
  }

  @override
  bool shouldReclip(_Crescent old) => old.direction != direction || old.geometry != geometry;
}

class _Ring extends CustomPainter {
  _Ring(this.color, this.geometry);

  final Color color;
  final _Geometry geometry;

  @override
  void paint(Canvas canvas, Size size) {
    canvas.drawPath(_avatarShape(Offset.zero & size, geometry.radius, geometry.seam), Paint()..color = color);
  }

  @override
  bool shouldRepaint(_Ring old) => old.color != color || old.geometry != geometry;
}

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