Two composables from a real design system: a single avatar, and a stack of them. Together they're about 60 lines. There are three decisions inside worth more than the code.
1. The fallback is the layout#
An avatar shows a photo. When there's no photo, it shows initials. The obvious implementation branches:
// the version most people write first
if (imageUrl != null) {
AsyncImage(model = imageUrl, ...)
} else {
Text(text = initials, ...)
}
That works. Here's the version that doesn't branch at all:
Box(
modifier = modifier
.size(size.dp)
.clip(CircleShape)
.clickable(onClick = { onClick?.invoke() }, enabled = onClick != null)
.background(MaterialTheme.colorScheme.extended.secondaryFill)
.border(width = 2.dp, color = MaterialTheme.colorScheme.outline, shape = CircleShape),
contentAlignment = Alignment.Center
) {
Text(
text = displayText.uppercase(),
style = MaterialTheme.typography.titleMedium,
color = textColor
)
AsyncImage(
model = imageUrl,
contentScale = ContentScale.Crop,
contentDescription = null,
modifier = Modifier.clip(CircleShape).matchParentSize()
)
}
Both children are always composed. The Text is drawn first, the AsyncImage second — and in a Box, later children draw on top.
When imageUrl is null, Coil draws nothing. The initials remain visible. When it isn't, the image covers them completely.
No conditional. No loading state. No flag to keep in sync. The stacking order is the logic.
This also gives you the loading behaviour for free. While the image is fetching, the initials are still what's on screen — so you get a meaningful placeholder instead of an empty circle, without writing a placeholder.
2. Modifier order is not cosmetic#
Look at the chain again, in order:
.size(size.dp)
.clip(CircleShape)
.clickable(...)
.background(...)
.border(...)
clip comes before clickable. That's deliberate: the ripple from the click is clipped to the circle. Swap those two lines and the ripple animates as a square inside a round avatar — a bug that's invisible in a screenshot and obvious the moment anyone taps.
Modifiers aren't a bag of properties. They're an ordered chain, and each one wraps the ones after it.
The click itself is optional without a second composable:
.clickable(
onClick = { onClick?.invoke() },
enabled = onClick != null
)
When onClick is null the node is disabled — no ripple, no accessibility action. One component covers both the interactive and the decorative case.
3. The size parameter is an enum#
This is the smallest decision in the file and the most design-system one:
enum class AvatarSize(val dp: Dp) {
SMALL(40.dp), LARGE(60.dp)
}
The alternative is size: Dp = 40.dp, and it looks more flexible. That flexibility is the problem: any call site can pass 37.dp, and eventually one does, and now your app has avatars at 40, 37, 44 and 52 because four people each made a locally reasonable choice.
An enum moves the decision from the call site to the system. A design system isn't a folder of components — it's the set of things a caller can't do.
4. Overlap is one negative number#
Now the stack. The chat-app pattern: a few avatars overlapping, then a badge with the count of everyone else.
The whole effect:
val overlapOffset = -(size.dp * overlapPercentage)
Row(
horizontalArrangement = Arrangement.spacedBy(overlapOffset),
verticalAlignment = Alignment.CenterVertically
) { ... }
Arrangement.spacedBy(16.dp) pushes children apart. Arrangement.spacedBy(-16.dp) pulls them together. That's it. No offset modifiers, no custom Layout, no Box with manual positioning.
Most people don't expect a layout arrangement to accept a negative value, which is why the usual solution to this is three times the code.
Note the offset is derived, not hardcoded:
val overlapOffset = -(size.dp * overlapPercentage)
A hardcoded -16.dp looks right at SMALL and wrong at LARGE. Deriving it from the size means both overlap by the same proportion, and a future XLARGE needs no changes.
The rest is bookkeeping:
val visibleAvatars = avatars.take(maxVisible)
val remainingCount = (avatars.size - maxVisible).coerceAtLeast(0)
take doesn't throw when the list is shorter than maxVisible, and coerceAtLeast(0) stops a negative remainder becoming a -2+ badge. Two standard-library calls instead of two if statements.
The part where one decision pays off in another#
Overlapping circles should be an unreadable blob. They aren't, because of a line in the other component:
.border(width = 2.dp, color = MaterialTheme.colorScheme.outline, shape = CircleShape)
That border exists so a single avatar has an edge against a light background. It also happens to be the thing that separates each avatar from the one behind it in a stack.
Take it out and the stack collapses visually. That's what a design system is actually for: a decision made once, in one component, paying off somewhere its author wasn't looking.
What I'd change#
Draw order. In a Row, later children draw on top — so the last avatar is in front, and the overflow badge sits above everything. Most designs do the opposite: first avatar in front, badge behind. Fixing it means Modifier.zIndex on each child in reverse. I didn't notice until I put mine next to a real app.
3+ should be +3. "3+" reads as "three or more". What it means is "three more". Slack, Linear and GitHub all use +3.
1+ shouldn't exist. If exactly one avatar is hidden, the badge takes the same space as just showing it. A maxVisible + 1 check is worth the two lines.
Accessibility. contentDescription = null is defensible while the initials are readable by the screen reader — but a stack currently announces four separate sets of initials, when what a user wants is "five participants". That's a Modifier.semantics(mergeDescendants = true) and a content description on the Row.
Wrapping up#
Three ideas, none of them about avatars:
Layering can replace branching. If two things are mutually exclusive on screen, ask whether stacking order can express that instead of an if.
Modifier order is behaviour. clip then clickable is a round ripple. The other way round is a square one.
Constrain the call site. The parameter you don't expose is the one that keeps a system coherent.
Until then, let's make this work.



