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.
Import
In a Flutter app where fliin init has run:
Examples
Basic
Members of a team, overlapping, each an FlAvatar whose initials stand in while its photo loads. The photos come from your app.
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.
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.
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.
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.
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.
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.
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.
















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
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;
}