Skip to content

feat(avatar): AvatarGroup with clipped separation and inherited sizing - #904

Merged
sean-brydon merged 7 commits into
mainfrom
avatar-group-clipping
Oct 19, 2026
Merged

sean-brydon merged 7 commits into
mainfrom
avatar-group-clipping

Conversation

@pasqualevitiello

Copy link
Copy Markdown
Contributor

Summary

AvatarGroup and AvatarGroupCount are new. Overlapping avatars are separated by masking each one with its neighbour's circle, so the gap is transparent rather than a coloured ring. A stack now holds its shape on gradients, photographs and translucent panels, where ring-2 ring-background does not.

Avatar takes every dimension from one inherited custom property, --avatar-size (default 32px). Font size scales with it, and a group derives overlap, gap and the initials nudge from it.

<AvatarGroup topmost="last">
  <Avatar>…</Avatar>
  <AvatarGroupCount>+2</AvatarGroupCount>
</AvatarGroup>

Technique

The cut is a vector circle composited out of the avatar's own circle:

mask-image: url("data:image/svg+xml,…<circle>…"), url(…same…)
mask-composite: subtract

Both layers are SVG, so the whole silhouette comes from one vector rasterisation. Three earlier approaches were rejected against real rendering:

approach failure
ring-2 ring-background wrong on any non-solid surface — the reason for this PR
radial-gradient with a 1px feather a CSS-pixel feather scales under browser zoom, so the cut edge blurs
radial-gradient with a hard stop gradient stops are not antialiased, so the edge stair-steps as the avatar grows

Masked children also carry rounded-none. With border-radius left on, its antialiased rim survives subtract and leaves a hairline ghost of the full circle — reproduced on a bare div, independent of Avatar.

Browser support

mask-composite is Baseline 2023: Chrome/Edge 120, Firefox 53, Safari 15.4. Against Tailwind v4's own floor (Chrome 111, Safari 16.4, Firefox 128) only Chrome and Edge 111–119 are exposed.

There the declaration is dropped and the two layers composite with add, turning the bite into a union — avatars render as hard-edged squares. An @supports guard gates both the mask and rounded-none, so those browsers get a plain overlap with no gap instead.

Visual changes to existing avatars

Existing Avatar JSX needs no edits, but two defaults changed:

  • AvatarFallback uses bg-border (black/8%) instead of bg-muted (black/4%). Every fallback is twice the contrast.
  • Avatar no longer paints bg-background. Visible only behind a transparent image with no fallback.

AvatarImage and AvatarFallback now follow the root radius via rounded-[inherit], so a rounded avatar no longer repeats its radius on the fallback.

Validation

  • bunx tsc --noEmit in apps/ui
  • bunx biome check on every changed file
  • bun run registry:validate-deps and registry:build in apps/ui
  • Rendered checks in the dev app at 16–208px, both topmost modes, LTR and RTL, light and dark, over page/muted/gradient/photo surfaces, and at 4× browser zoom
  • render={<button>} / render={<a>} verified to emit real BUTTON and A elements with accessible names; group tooltips verified to open with clipping intact

Merge Danger

Door: two-way

Blast Radius: @coss/avatar and its consumers

Any app rendering AvatarFallback sees the darker surface after updating. Group clipping is new API, so nothing regresses there. Nine particles across select, combobox, button, skeleton and group moved from size-* to --avatar-size; their boxes are unchanged but fallback type now scales, so combobox and select avatars show 7.5px initials where they showed a hand-set 10px.

Rollback

Revert the five commits; avatar.tsx and packages/ui/src/components/avatar.tsx return to the ring-based stack.

🤖 Generated with Claude Code

pasqualevitiello and others added 5 commits October 18, 2026 19:26
Overlapping avatars are separated by masking each one with its
neighbour's circle, so the gap is transparent rather than a coloured
ring. The stack keeps its shape on gradients, images and dark
surfaces, where a ring tinted with the page background does not.

- AvatarGroup, with `topmost` choosing which end stays a whole circle
- AvatarGroupCount for the "+N" chip, labelled in muted foreground
- Size, overlap, gap and initials nudge read inheritable
  --avatar-size, --avatar-overlap, --avatar-gap and --avatar-nudge,
  settable on the avatar, the group or any ancestor
- Fallback initials shift out of the clipped edge so they read as
  centred in what is left of the circle
- The cut is an SVG circle composited with mask-composite: subtract,
  which stays crisp at any avatar size and under browser zoom
- An @supports guard degrades to a plain overlap on Chrome 111-119,
  where mask-composite would otherwise turn the bite into a union

Avatar now sizes from --avatar-size and scales its type with it, and
the image and fallback follow the root radius through
rounded-[inherit], so a rounded avatar no longer repeats its radius.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Swap the arbitrary mask properties for Tailwind's own utilities, which
compile to identical CSS: mask-subtract, mask-no-repeat and the
mask-size / mask-position functional forms. The supports-[] prefixes
stay in bracket form, since an @supports condition takes a raw
declaration rather than a utility name.

Particles now size avatars through --avatar-size instead of a size-*
class, so the fallback type scales with the circle rather than being
hand-corrected per call site:

- p-select-20 drops size-8 entirely, since it restated the default
- p-button-28 and p-skeleton-1 move to --avatar-size, keeping their
  24px and 40px boxes while the type follows
- p-combobox-19/20, p-select-19 and p-group-23 move to --avatar-size
  and drop their text-[...] overrides on AvatarFallback

No Avatar in the registry now carries a size-* class, and no
AvatarFallback carries a text-* class; both dimensions come from the
one custom property.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three examples were missing for the new API, so add them and put the
gallery in a deliberate order rather than the order things were built.

- p-avatar-17 shows a group over a gradient, which is the whole point
  of clipping instead of ringing
- p-avatar-18 shows --avatar-overlap and --avatar-gap being tuned
- p-avatar-19 shows --avatar-size set on a container, sizing several
  avatars at once

Particle names are left alone so installed copies keep resolving; only
the registry order changes. It now runs single avatars, then status and
badges, then groups from simple to tuned.

The docs gain a Sizing section stating the rule plainly: one inherited
property drives the box, the type and the group geometry, and size-*
resizes the box alone. AvatarGroup lists its constraints explicitly —
uniform size within a group, badges only on the avatar that stays
whole, and the Chrome 111-119 fallback — since those are the things a
reader or an agent would otherwise have to infer.

Adds a changelog entry with a migration diff and an agent migration
prompt covering the size-*, fallback text and ring-stack rewrites.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…emos

Replace the gradient and custom-geometry examples with the ones people
actually reach for.

- p-avatar-17 renders an avatar as a button, p-avatar-18 as a link,
  both through Base UI's render prop with an accessible name and a
  focus-visible outline
- p-avatar-20 pairs a single avatar with a tooltip
- p-avatar-21 gives every member of a group its own tooltip

TooltipTrigger renders the avatar itself, so the trigger and the avatar
are one element. That also means it replaces the avatar's data-slot
with tooltip-trigger, which the docs now call out: CSS written against
[data-slot=avatar] will not match a tooltipped avatar. The group's
clipping is unaffected because it targets direct children rather than
that attribute.

The link example carries a suppression for useAnchorContent. The rule
wants literal children on the anchor, which a render prop can never
have, since Avatar supplies them; the rendered anchor does have an
image with alt text and an aria-label.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The page had grown to eighteen previews. Back to the density it had
before: Fallback Only, Different Radius and Group Avatars as they were,
plus Last Avatar On Top for the one prop AvatarGroup takes. The size
scale stays in the Sizing section, where the rule it illustrates is
stated.

Everything else — icon fallback, button and link avatars, tooltips,
status dots, badges, group sizes, parent-driven sizing — remains in the
particles gallery, which is where the long tail belongs. The render
prop and inherited sizing are still covered in prose and code samples
under API Reference.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@vercel

vercel Bot commented Oct 18, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
coss-com Ready Ready Preview Oct 18, 2026 6:29pm UTC
coss-com-ui Ready Ready Preview Oct 18, 2026 6:29pm UTC
coss-examples-calcom Ready Ready Preview Oct 18, 2026 6:29pm UTC
1 Skipped Deployment
Project Deployment Actions Updated
coss-com-origin Skipped Skipped Oct 18, 2026 6:29pm UTC

Request Review

Component pages carry a Changelog section pointing at the dated entry,
as scroll-area, form, table, calendar, otp-field and toggle-group do.
Avatar was missing one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
AvatarImage declares keepMounted and onLoadingStatusChange, and
AvatarFallback declares delay. The last two were not documented
anywhere. They now sit in Prop/Type/Default tables, matching how
tooltip and toggle-group present theirs.

Avatar and AvatarGroupCount stay prose-only, since neither declares a
prop of its own — Avatar now says so explicitly and points at render
for button and link avatars.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@pasqualevitiello

Copy link
Copy Markdown
Contributor Author

I tested this on cal in calcom/cal#11066, copying avatar.tsx over cal's vendored coss-ui verbatim. Then I planned the migration for all 73 Avatar importers and the 37 consumers of cal's UserAvatar composite. The swap on its own is visually safe. An unmigrated size-N call site keeps its 12px initials, because --avatar-size stays unset and the font resolves to 32px × 0.375. The six points below show up only once call sites follow the migration notes.

1. Derived initials are too small below 32px. After migration, initials are N × 1.5px, so size-4 gives 6px, size-5 7.5px and size-6 9px. Cal already pins about ten size-5 avatars to text-[.625rem] (10px), and its size-4 ones to text-[8px]. The design consistently wants a floor of about 8–10px. A floor in the base class would remove most of those overrides, for example text-[max(--spacing(2.5),calc(var(--avatar-size,--spacing(8))*0.375))]. Otherwise, the docs could say that small sizes need a fallback override.

2. The changelog's stack diff reverses which avatar sits on top. In a flex -space-x-2 stack, later siblings paint on top, so the last avatar is the whole one. The diff converts that stack to <AvatarGroup> with the default topmost="first", which flips it. All twelve of cal's stacks need topmost="last". Either add topmost="last" to the changelog diff and the agent migration prompt, or make "last" the default.

3. Interactive group children need a documented pattern. Six of cal's twelve stacks wrap each avatar in a <Link>, an <a>, an <li> or a tooltip <span>. That wrapper becomes the group's direct child, so it gets masked instead of the avatar. The fix is to make the avatar the element itself, for example TooltipTrigger render={<Avatar render={<Link href=… />} />}, which is the nesting cal's public booking page needs. A short example of that next to "Group Avatars" would save consumers the guesswork.

4. AvatarGroup has no render prop. Two cal stacks are <ul>/<li> lists and lose that semantics when they become a <div>. In another, the whole stack is a DialogTrigger button, so the group has to sit inside the button instead of becoming it. useRender on AvatarGroup would cover both cases.

5. Groups only overlap horizontally. Cal's app-shell header overlaps the org and user avatars side by side on lg, then stacks them vertically on md and in the collapsed sidebar. Group geometry is horizontal only, so that stack stays hand-made. That may be fine as a non-goal, but it's worth saying in the docs.

6. The new background defaults are visible. The bg-muted → bg-border change and the root's lost bg-background both show in cal:

  • transparent team, org and OAuth-client logos now show the surface behind them, including over a banner image;
  • fallback-only avatars read darker.

This matches the changelog, so nothing needs to change upstream. The note about setting those classes back explicitly is the important part, and it might deserve a line in the agent migration prompt about logos specifically.

@pasqualevitiello
pasqualevitiello marked this pull request as ready for review October 19, 2026 10:07
@sean-brydon
sean-brydon merged commit 7f7cbd0 into main Oct 19, 2026
13 checks passed
@sean-brydon
sean-brydon deleted the avatar-group-clipping branch October 19, 2026 10:10
@linear-code

linear-code Bot commented Oct 19, 2026

Copy link
Copy Markdown

ENG-6435

This branch was successfully deployed

3 active and 1 inactive deployments
Preview – coss-com-ui — 2efc31c6 Deployed Oct 18, 2026 by vercel[bot]
Preview – coss-examples-calcom — 2efc31c6 Deployed Oct 18, 2026 by vercel[bot]
Preview – coss-com — 2efc31c6 Deployed Oct 18, 2026 by vercel[bot]
Preview – coss-com-origin — 2efc31c6 Deployed Oct 18, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants