# Avatar group

> A row of avatars for the people on a board, an event or a course: overlapping or spaced, up to a limit, then a +N bubble counted from the real total, with optional caption text. Initials stand in for missing photos.

- ID: `cmp_avatar_group_01`
- Slug: `avatar-group-01`
- Version: `1.0.0` (current)
- Status: published
- Published: 2026-10-21T20:57:16Z
- Updated: 2026-10-21
- Available versions: `1.0.0`
- Kind: control
- Primary category: `typography`
- Detail page: https://pagesugar.com/components/avatar-group-01?variant=neutral
- Preview: https://pagesugar.com/preview/avatar-group-01

## Variants

| Variant | Label | Default | Artifact digest |
| --- | --- | --- | --- |
| `neutral` | Neutral | yes | `sha256-0dc0d148556e871ef7c8b374359d02ca91f8b776743b5ccdf9b4ea1757a723af` |

## Runtime and compatibility

- Runtime: svelte
- Svelte: 5
- SvelteKit required: no (portable Svelte component)
- Imports: The source imports its own files by relative path only. Examples import from $lib/...; SvelteKit 3 projects have no $lib alias by default, so import from your #lib subpath (with the .svelte extension) or a relative path instead.
- Tailwind CSS: 4
- SSR: supported
- Requires client-side JavaScript: no
- Integration level: presentational
- Appearance modes: light
- Suggested directory: `src/lib/components/avatar-group-01`

## Dependencies

No third-party runtime packages.

## Services

No external services required.

## Usage

Pass people in display order, a limit and, when people is only a sample, the real total. It renders a list of avatars, a +N bubble and an optional caption. It does not fetch people, does not show who is hidden (no popover or tooltip), does not invent or round counts, and renders nothing when people is empty.

Required props: `people`

```svelte
<script lang="ts">
	import AvatarGroup from '$lib/components/avatar-group-01/AvatarGroup.svelte';

	const members = [
		{ name: 'Mara Lindqvist', src: '/avatars/mara.jpg', href: '/people/mara' },
		{ name: 'Theo Okafor', src: '/avatars/theo.jpg', href: '/people/theo' },
		{ name: 'Priya Raman', href: '/people/priya' },
		{ name: 'Jonas Weber', src: '/avatars/jonas.jpg', href: '/people/jonas' },
		{ name: 'Aiko Tanaka', src: '/avatars/aiko.jpg', href: '/people/aiko' }
	];
</script>

<AvatarGroup
	people={members}
	max={4}
	total={6}
	size="md"
	label="Mobile app 2.0 members"
	caption="6 members"
/>
```

Limitations:

- The ring that separates overlapping faces is a solid colour. On a photo, gradient or patterned background it shows as a coloured ring; use overlap={false} there.
- Overlapping faces do not wrap: the stack is as wide as max faces, about 30 px per md face. Keep max to what fits the narrowest column (eight xs or five md faces fit 360 px beside a short caption), or use overlap={false}, which wraps.
- Overlapping links are 24, 32 or 40 px wide and partly covered by the next face, so they are smaller targets than 44 px on touch screens. Use md, or spaced, where people tap faces often.
- Hidden people are only counted. There is no popover, tooltip or list of who they are; link the caption to a full list if people need it.
- The +N count uses the locale prop (en by default) so the server and the browser format it the same way; set it to the page's language.
- Initials are the first letter of the first and last word of the name. For names where that is wrong (some East Asian orders, mononyms with particles), pass a src or a shorter display name.
- Light mode only by default. On a dark surface set --avatar-group-ring to that surface and retone fill, ink, muted and hairline.
- Without JavaScript everything renders, but an image that fails to load shows the browser's broken-image box with the person's name as its text instead of initials. The fallback to initials runs after hydration.

## Usage guide

### Avatar group

A list of faces for the people on something: a board, a class, a course. Up to `max` faces show,
then a `+N` bubble counts the rest.

#### The count

`N` is `(total ?? people.length)` minus the faces shown. Pass `total` when `people` is a sample
(the first five learners of 1,284), and take it from the same query that produced the sample.
The component never rounds, estimates or pads it. The bubble formats `N` with
`Intl.NumberFormat(locale)`, so `1279` becomes `+1,279` and the bubble widens into a pill
rather than shrinking its type. Write the caption's number yourself from the same total.

#### The ring

Overlapping faces are cut apart by a two-pixel ring in `--avatar-group-ring`. It has to be the
colour of the surface directly behind the group, or it shows as an outline:

```svelte
<div class="rounded-xl bg-zinc-50 p-4 [--avatar-group-ring:#fafafa]">
	<AvatarGroup people={members} label="Reviewers" />
</div>
```

On a photo or gradient, use `overlap={false}`.

#### Stacking and focus

The first face sits on top. A hovered link rises above its neighbours and lifts by two pixels;
a keyboard-focused link rises above everything so its focus ring is never covered. The root
isolates its stacking, so none of this escapes onto your page.

#### Images

A face without `src`, or whose image fails to load, shows the name's initials on the neutral
fill at the same size. That fallback is the only client-side work the component does.

## Props

| Name | Type | Required | Default | Description |
| --- | --- | --- | --- | --- |
| `people` | `AvatarGroupPerson[]` | yes |  | People in display order, each { name: string; src?: string; href?: string }. The first sits on top of the stack. An empty array renders nothing. |
| `max` | `number` | no | `4` | How many avatars show before the +N bubble. At least 1. |
| `total` | `number` | no |  | The real total when people is a sample, from your data. The bubble counts total minus the faces shown. Ignored when smaller than people.length or not a finite number. |
| `size` | 'xs' \| 'sm' \| 'md' | no | `'sm'` | Avatar diameter: 24, 32 or 40 px. The bubble and the caption follow. |
| `overlap` | `boolean` | no | `true` | Stack the faces by a quarter of their width with a ring in --avatar-group-ring, or set them apart with a small gap and no ring. |
| `label` | `string` | no |  | Accessible name for the list, such as "Project members". |
| `overflowLabel` | `(hidden: number) => string` | no |  | Accessible text for the +N bubble, given the hidden count. Defaults to "{n} more" with n formatted for locale. |
| `caption` | `string` | no |  | Text beside the group, such as "6 members" or "Joined by 1,284 learners". Write the number yourself, from the same total. |
| `locale` | `string` | no | `'en'` | Locale for formatting the +N count and upper-casing initials. |
| `class` | `string` | no |  | Extra classes on the root, for margins or alignment. |

## Customization

Pick size, overlap and max through props. Retone with --avatar-group-\* variables; --avatar-group-ring must match the surface behind the group. Edit the SIZES map in the source to change diameters, overlap or caption size.

- Surface: set --avatar-group-ring to the colour directly behind the group (white by default). On a zinc-50 card, set it to #fafafa; on a cream page, to that cream.
- Initials and bubble: --avatar-group-fill is their background, --avatar-group-muted the initials, --avatar-group-ink the +N count. Keep each at 4.5:1 on the fill.
- Edges: --avatar-group-hairline is a one-pixel inner line over every face so a pale photo keeps its edge. Set it to transparent if your photos are all dark.
- Focus: --avatar-group-accent is the focus ring on linked faces.
- Dark surface: set --avatar-group-ring to the surface (for example #18181b), --avatar-group-fill to #27272a, --avatar-group-ink to #fafafa, --avatar-group-muted to #a1a1aa, --avatar-group-hairline to rgb(255 255 255 / 0.1) and --avatar-group-accent to #fafafa.
- Shape: faces are circles. For rounded squares, change rounded-full to rounded-lg on the face, its hairline and the link, and keep the bubble the same.
- Who is hidden: wrap the caption in a link to the full member list; the group itself does not open anything.

| Token | Public CSS variable |
| --- | --- |
| `accent` | `--avatar-group-accent` |
| `ink` | `--avatar-group-ink` |
| `muted` | `--avatar-group-muted` |
| `hairline` | `--avatar-group-hairline` |
| `fill` | `--avatar-group-fill` |
| `ring` | `--avatar-group-ring` |

## Accessibility

- Renders a ul with role="list" (so Safari keeps list semantics without markers), named by label, with one li per face and one for the +N bubble, so a screen reader announces the list, its name and how many items it holds.
- Each face has an accessible name: the image's alt is the person's name, and the initials fallback is role="img" with the name as its label. The letters themselves are hidden.
- The bubble shows +N and reads its accessible text instead ("2 more" by default, or your overflowLabel).
- Linked faces are ordinary links in reading order, named by the person. On keyboard focus a two-pixel accent ring appears two pixels out and the focused face rises above its neighbours, so the ring is never covered.
- Hover lift and the press settle are turned off under prefers-reduced-motion.
- Initials and the +N count clear 4.5:1 on the default fill. Re-check after retoning.
- The consumer supplies honest names, image alternatives and a total from real data; the component never estimates a count.

Known limitations:

- Overlapping links are below the 44 px touch target; see the usage limitations.

## License

- Declared source: MIT
- Default license approval is pending. See https://pagesugar.com/docs/license.

## Source

- Palette: Neutral (`neutral`)
- Entry: `AvatarGroup.svelte`
- Suggested directory: `src/lib/components/avatar-group-01`
- Files: 1
- Artifact digest: `sha256-0dc0d148556e871ef7c8b374359d02ca91f8b776743b5ccdf9b4ea1757a723af`

Paths below are relative to the suggested directory. Copy the files as they are;
they import nothing from this site.

#### `AvatarGroup.svelte`

Role: entry · 8482 bytes · SHA-256 `43270016212f2a55333b48f9f9fc5f5835d7394db7e65a45f6519aba58d35160`

```svelte
<script lang="ts" module>
	export type AvatarGroupSize = 'xs' | 'sm' | 'md';

	export interface AvatarGroupPerson {
		/** Full name: the image's alternative text and the source of the initials fallback. */
		name: string;
		/** Image URL. Omitted, or failing to load, the avatar shows the name's initials. */
		src?: string;
		/** Profile URL. Given, the avatar is a link. */
		href?: string;
	}
</script>

<script lang="ts">
	interface Props {
		/** People in display order; the first sits on top of the stack. */
		people: AvatarGroupPerson[];
		/** How many avatars show before the +N bubble. */
		max?: number;
		/** The real total when people is a sample; drives the +N count. Never an estimate. */
		total?: number;
		/** Avatar diameter: xs 24 px, sm 32 px, md 40 px. */
		size?: AvatarGroupSize;
		/** Stack the avatars with a ring in the surface colour, or space them apart. */
		overlap?: boolean;
		/** Accessible name for the list, such as "Project members". */
		label?: string;
		/** Accessible text for the +N bubble. Receives the hidden count. */
		overflowLabel?: (hidden: number) => string;
		/** Optional text beside the group, such as "6 members". */
		caption?: string;
		/** Locale for the +N count. Fixed by default so server and browser agree. */
		locale?: string;
		/** Extra classes on the root, for margins or alignment. */
		class?: string;
	}

	let {
		people,
		max = 4,
		total,
		size = 'sm',
		overlap = true,
		label,
		overflowLabel,
		caption,
		locale = 'en',
		class: className
	}: Props = $props();

	/*
	 * Each size is one box, shared by the avatars and the +N bubble so the row reads as one
	 * object. Overlap is a quarter of the diameter. `touch` grows linked md faces to 44 px on a
	 * coarse pointer. Complete class names, never interpolated.
	 */
	const SIZES: Record<
		AvatarGroupSize,
		{
			box: string;
			bubble: string;
			text: string;
			overlap: string;
			caption: string;
			touch?: { box: string; bubble: string };
		}
	> = {
		xs: {
			box: 'size-6',
			bubble: 'h-6 min-w-6',
			text: 'text-[0.625rem]',
			overlap: '[--_overlap:0.375rem]',
			caption: 'text-xs leading-4'
		},
		sm: {
			box: 'size-8',
			bubble: 'h-8 min-w-8',
			text: 'text-xs',
			overlap: '[--_overlap:0.5rem]',
			caption: 'text-sm leading-5'
		},
		md: {
			box: 'size-10',
			bubble: 'h-10 min-w-10',
			text: 'text-sm',
			overlap: '[--_overlap:0.625rem]',
			caption: 'text-sm leading-5',
			touch: {
				box: 'pointer-coarse:size-11',
				bubble: 'pointer-coarse:h-11 pointer-coarse:min-w-11'
			}
		}
	};

	const step = $derived(SIZES[size] ?? SIZES.sm);
	const touch = $derived(people.some((person) => person.href) ? step.touch : undefined);
	const limit = $derived(Math.max(1, Math.floor(Number.isFinite(max) ? max : 4)));
	const shown = $derived(people.slice(0, limit));
	const count = $derived(
		Math.max(people.length, Number.isFinite(total) ? Math.floor(total as number) : 0)
	);
	const hidden = $derived(count - shown.length);
	const format = $derived(new Intl.NumberFormat(locale));
	const hiddenText = $derived(format.format(hidden));
	const hiddenLabel = $derived(overflowLabel ? overflowLabel(hidden) : `${hiddenText} more`);

	/* Images that failed to load fall back to initials; this is the only client-side work. */
	let failed = $state<string[]>([]);
	const markFailed = (src: string) => {
		if (!failed.includes(src)) failed = [...failed, src];
	};

	/** First letter of the first and last word, read as whole characters, not code units. */
	const initials = (name: string) => {
		const words = name.trim().split(/\s+/).filter(Boolean);
		const first = Array.from(words[0] ?? '')[0] ?? '';
		const last = words.length > 1 ? (Array.from(words[words.length - 1])[0] ?? '') : '';
		return (first + last).toLocaleUpperCase(locale);
	};

	const face = $derived([
		'avatar-group__face relative inline-flex shrink-0 items-center justify-center overflow-hidden rounded-full bg-(--_fill) font-medium text-(--_muted) select-none',
		step.box,
		touch?.box,
		step.text,
		overlap && 'shadow-(--_cut)'
	]);
</script>

{#snippet avatar(person: AvatarGroupPerson)}
	<span class={face}>
		{#if person.src && !failed.includes(person.src)}
			<img
				class="size-full object-cover"
				src={person.src}
				alt={person.name}
				loading="lazy"
				decoding="async"
				onerror={() => markFailed(person.src!)}
			/>
		{:else}
			<span class="ps-(--_shift)" role="img" aria-label={person.name}>
				<span aria-hidden="true">{initials(person.name)}</span>
			</span>
		{/if}
		<!-- A hairline drawn over the face, so a pale photo keeps its edge on a pale page; it
			darkens on hover when the face is a link. -->
		<span
			class="pointer-events-none absolute inset-0 rounded-full inset-ring inset-ring-(--_hairline) transition-[box-shadow] duration-150 ease-(--_ease) group-hover/avatar:inset-ring-(--_edge) motion-reduce:transition-none"
			aria-hidden="true"
		></span>
	</span>
{/snippet}

{#if shown.length > 0}
	<div
		class={[
			'avatar-group inline-flex max-w-full flex-wrap items-center gap-x-3 gap-y-2',
			className
		]}
		style:--_top={shown.length + 2}
	>
		<!-- role="list" keeps list semantics in Safari, which drops them from a ul with no markers. -->
		<ul
			role="list"
			class={[
				'isolate flex items-center',
				overlap ? ['shrink-0', step.overlap] : 'min-w-0 flex-wrap gap-1'
			]}
			aria-label={label}
		>
			{#each shown as person, index (index)}
				<li
					class={[
						'avatar-group__item relative z-(--_z) flex',
						overlap && 'not-first:-ms-(--_overlap) not-first:[--_shift:var(--_overlap)]'
					]}
					style:--_z={shown.length - index + 1}
				>
					{#if person.href}
						<a
							class="avatar-group__link group/avatar flex rounded-full outline-offset-2 outline-(--_accent) transition-[scale] duration-[80ms] ease-(--_ease) focus-visible:outline-2 active:scale-[.96] motion-reduce:transition-none motion-reduce:active:scale-100"
							href={person.href}
						>
							{@render avatar(person)}
						</a>
					{:else}
						{@render avatar(person)}
					{/if}
				</li>
			{/each}
			{#if hidden > 0}
				<li
					class={[
						'avatar-group__item relative z-(--_z) flex',
						overlap && 'not-first:-ms-(--_overlap) not-first:[--_shift:var(--_overlap)]'
					]}
					style:--_z={1}
				>
					<span
						class={[
							'avatar-group__count inline-flex shrink-0 items-center justify-center rounded-full bg-(--_ring) px-2 font-medium text-(--_ink) tabular-nums inset-ring inset-ring-(--_rule)',
							step.bubble,
							touch?.bubble,
							step.text,
							overlap && 'shadow-(--_cut)'
						]}
					>
						<span class="ps-(--_shift)" aria-hidden="true"
							><span dir="ltr">+{hiddenText}</span></span
						>
						<span class="sr-only">{hiddenLabel}</span>
					</span>
				</li>
			{/if}
		</ul>
		{#if caption}
			<p class={['min-w-0 text-pretty text-(--_muted) tabular-nums', step.caption]}>{caption}</p>
		{/if}
	</div>
{/if}

<style>
	/*
	 * Public tokens: set --avatar-group-* on the group or any ancestor. --avatar-group-ring must
	 * match the surface behind the group, because the ring is what cuts each face from the one
	 * beneath it. On a dark or tinted surface, set it to that surface.
	 */
	.avatar-group {
		--_accent: var(--avatar-group-accent, #18181b);
		--_ink: var(--avatar-group-ink, #18181b);
		--_muted: var(--avatar-group-muted, #52525b);
		--_hairline: var(--avatar-group-hairline, rgb(0 0 0 / 0.08));
		--_fill: var(--avatar-group-fill, #f4f4f5);
		--_ring: var(--avatar-group-ring, #ffffff);

		/*
		 * Formulas: the two-pixel cut in the surface colour, a linked face's hover edge, the +N
		 * pill's outline (it sits on the surface, so it reads as a count, not a face), and the
		 * state-change curve. --_shift moves a covered face's letters toward its visible part.
		 */
		--_cut: 0 0 0 2px var(--_ring);
		--_edge: color-mix(in srgb, var(--_ink) 40%, transparent);
		--_rule: color-mix(in srgb, var(--_ink) 16%, transparent);
		--_shift: 0px;
		--_ease: cubic-bezier(0.2, 0, 0, 1);
	}

	/*
	 * Relations between items: each face's z-index comes from --_z (first on top), and a hovered
	 * or focused face rises above every neighbour so the whole face and its focus ring show.
	 */
	@media (hover: hover) {
		.avatar-group__item:has(> .avatar-group__link:hover) {
			z-index: var(--_top);
			--_shift: 0px;
		}
	}

	.avatar-group__item:has(> .avatar-group__link:focus-visible) {
		z-index: calc(var(--_top) + 1);
		--_shift: 0px;
	}
</style>
```

## Artifacts

### Neutral (`neutral`) (default)

- Artifact digest: `sha256-0dc0d148556e871ef7c8b374359d02ca91f8b776743b5ccdf9b4ea1757a723af`
- Entry: `AvatarGroup.svelte`
- Receipt: https://pagesugar.com/artifacts/cmp_avatar_group_01/1.0.0/neutral/sha256-0dc0d148556e871ef7c8b374359d02ca91f8b776743b5ccdf9b4ea1757a723af/manifest.json
- Bundle: https://pagesugar.com/artifacts/cmp_avatar_group_01/1.0.0/neutral/sha256-0dc0d148556e871ef7c8b374359d02ca91f8b776743b5ccdf9b4ea1757a723af/bundle.zip (6705 bytes, sha256 `b40d6dcdfa4f9d65c916ad894ae9aeead0d3771ab3ba34264e4bac9e08eedb64`)

Files:

- `AvatarGroup.svelte` (entry, 8482 bytes): https://pagesugar.com/artifacts/cmp_avatar_group_01/1.0.0/neutral/sha256-0dc0d148556e871ef7c8b374359d02ca91f8b776743b5ccdf9b4ea1757a723af/source/AvatarGroup.svelte
