Skip to content
Palette Neutral
Download ZIP

Neutral palette · 6.5 KB ZIP File receipt View as Markdown View code

Preview

Fit to the available width. The frame follows the height of its content; previews taller than the maximum auto-height scroll inside it.

Give this component to your coding agent Copy a prompt that fetches this exact version and palette through the PageSugar MCP server.
cmp_avatar_group_01 · version 1.0.0 · Neutral palette
Using the PageSugar MCP server, fetch component cmp_avatar_group_01 version 1.0.0 with variant "neutral", first inspect its requirements and license status and confirm this project uses Svelte 5 and Tailwind CSS 4. Retrieve every manifest file, including binary assets and any manifest-only response files, preserving relative paths. Then integrate the source and follow its usage notes. Run project checks, review the browser result and report anything unverified. Do not substitute another version or invent missing files.

Not connected yet? Set up the MCP server

Code

Palette
Neutral
Version
1.0.0
Digest
Full digest
sha256-0dc0d148556e871ef7c8b374359d02ca91f8b776743b5ccdf9b4ea1757a723af
AvatarGroup.svelte Svelte · 8.3 KB Raw
<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>

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.

Suggested location
src/lib/components/avatar-group-01
Required props
people

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.

Example

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"
/>

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.

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 and content inputs#

On this page
NameTypeRequiredDefaultDescription
peopleAvatarGroupPerson[]YesNonePeople in display order, each { name: string; src?: string; href?: string }. The first sits on top of the stack. An empty array renders nothing.
maxnumberNo4How many avatars show before the +N bubble. At least 1.
totalnumberNoNoneThe 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.
overlapbooleanNotrueStack 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.
labelstringNoNoneAccessible name for the list, such as "Project members".
overflowLabel(hidden: number) => stringNoNoneAccessible text for the +N bubble, given the hidden count. Defaults to "{n} more" with n formatted for locale.
captionstringNoNoneText beside the group, such as "6 members" or "Joined by 1,284 learners". Write the number yourself, from the same total.
localestringNo'en'Locale for formatting the +N count and upper-casing initials.
classstringNoNoneExtra classes on the root, for margins or alignment.

Customization#

On this page

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.

Public CSS variables

VariableToken
--avatar-group-accentaccent
--avatar-group-inkink
--avatar-group-mutedmuted
--avatar-group-hairlinehairline
--avatar-group-fillfill
--avatar-group-ringring

Accessibility#

On this page
  • 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.

Release details#

On this page
Integration
  • Presentational
  • Works without client-side JavaScript
  • Server-side rendering supported
Dependencies
No additional runtime packages beyond Svelte and Tailwind CSS
License

MIT. Default license approval is pending; see the license status before adopting the source.

Version history
  • 1.0.0 (Published) Current release · 21 October 2026

Only the current release is available. Keep downloaded source and its receipt if you need to use it again later.