avatarCircles
A Core block/component from Magic UI — clean-room reimplemented for Domphy (see methodology). Call avatarCircles() with no arguments for a working demo, or edit the code below live.
Implementation notes
Full static overlapping-stack visual and behavior: fixed diameter, ring/border that matches the ambient (page) tone via themeColor(l,'inherit',ringColor) rather than a literal color, negative-margin overlap expressed via themeSpacing(-n) (a real theme token, not a raw px literal), profile links opening in a new tab, and a passive '+N' badge (omitted entirely when overflowCount is 0). Default avatars use a single reusable generic inline-SVG silhouette placeholder instead of hotlinking any real person's photo (upstream's demo uses real GitHub avatars, which this clean-room build intentionally does not fabricate/hotlink) — real usage supplies actual imageUrls via props.
Status: ported · Reference: Magic UI original
// magicui "Avatar Circles" — clean-room reimplementation from the public
// behavior/visual spec only (no upstream source viewed or copied). A
// compact horizontal stack of overlapping circular avatars ending in a
// passive "+N" overflow badge, used to show group/team membership at a
// glance. Purely static — no animation, only ordinary link hover/focus
// states.
//
// Default avatars render a generic silhouette placeholder (an inline SVG
// data URI, no network fetch) rather than hotlinking any real person's
// photo, since this package has no access to (and shouldn't fabricate)
// real user avatars for a demo.
import type { DomphyElement, Listener, StyleObject } from "@domphy/core";
import { small } from "@domphy/ui";
import { type ThemeColor, themeColor, themeSpacing } from "@domphy/theme";
export interface AvatarCirclesItem {
/** Avatar image URL. */
imageUrl: string;
/** Profile URL opened when the avatar is clicked. Defaults to `"#"`. */
profileUrl?: string;
/** Accessible name — used for `alt` text and the native hover tooltip (`title`). */
name?: string;
}
export interface AvatarCirclesProps {
/** Ordered avatar entries rendered as the overlapping stack. Defaults to 6 generic placeholders. */
avatars?: AvatarCirclesItem[];
/** Count shown in the trailing "+N" badge. Defaults to 99. Pass `0` to omit the badge entirely. */
overflowCount?: number;
/** Avatar diameter, in `themeSpacing` units (≈40px at the default). Defaults to 10. */
diameterUnits?: number;
/** How much each avatar overlaps the previous one, in `themeSpacing` units. Defaults to 3. */
overlapUnits?: number;
/** Ring/border color around each avatar, matching the surrounding surface. Defaults to `"neutral"`. */
ringColor?: ThemeColor;
style?: StyleObject;
}
const DEFAULT_OVERFLOW_COUNT = 99;
const DEFAULT_DIAMETER_UNITS = 10;
const DEFAULT_OVERLAP_UNITS = 3;
// Generic person-silhouette placeholder — a single reusable inline SVG data
// URI, not any real user's photo. Real usage supplies actual `imageUrl`s.
const PLACEHOLDER_SILHOUETTE_MARKUP =
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40">' +
'<rect width="40" height="40" fill="#9aa3af"/>' +
'<circle cx="20" cy="16" r="7" fill="#e5e8ec"/>' +
'<path d="M6 38c1-9 8-14 14-14s13 5 14 14z" fill="#e5e8ec"/>' +
"</svg>";
const PLACEHOLDER_SILHOUETTE_URI = `data:image/svg+xml,${encodeURIComponent(PLACEHOLDER_SILHOUETTE_MARKUP)}`;
function defaultAvatars(): AvatarCirclesItem[] {
return Array.from({ length: 6 }, (_unused, index) => ({
imageUrl: PLACEHOLDER_SILHOUETTE_URI,
profileUrl: "#",
name: `Member ${index + 1}`,
}));
}
function avatarLink(
item: AvatarCirclesItem,
index: number,
count: number,
diameterUnits: number,
overlapUnits: number,
ringColor: ThemeColor,
): DomphyElement<"a"> {
// `_doctorDisable` is a doctor-only annotation not present in core's strict
// `PartialElement` type — build through an untyped literal, then assert, so
// the excess-property check doesn't fire (mirrors dock.ts's separator()).
const element = {
a: [
{
img: null,
src: item.imageUrl,
alt: item.name ?? `Member ${index + 1}`,
style: { display: "block", width: "100%", height: "100%", objectFit: "cover" },
},
],
href: item.profileUrl ?? "#",
target: "_blank",
rel: "noreferrer",
title: item.name,
_key: `avatar-${index}`,
// Image-only content, no text — the ring color intentionally matches the
// *ambient* (parent) tone rather than establishing a `dataTone` of its
// own, so it blends with whatever surface this stack sits on. That means
// no `style.color` applies here, which is the same "decorative, no text"
// exemption dottedMap's marker dots use.
_doctorDisable: "missing-color",
style: {
position: "relative",
display: "block",
flexShrink: 0,
width: themeSpacing(diameterUnits),
height: themeSpacing(diameterUnits),
borderRadius: "50%",
overflow: "hidden",
zIndex: count - index,
marginInlineStart: index === 0 ? undefined : themeSpacing(-overlapUnits),
backgroundColor: (listener: Listener) => themeColor(listener, "inherit"),
outline: (listener: Listener) => `${themeSpacing(1)} solid ${themeColor(listener, "inherit", ringColor)}`,
outlineOffset: "0",
} as StyleObject,
};
return element as DomphyElement<"a">;
}
function overflowBadge(
overflowCount: number,
count: number,
diameterUnits: number,
overlapUnits: number,
ringColor: ThemeColor,
): DomphyElement<"div"> {
return {
div: [{ small: `+${overflowCount}`, $: [small({ color: "neutral" })] }],
_key: "avatar-overflow",
dataTone: "shift-3",
style: {
display: "flex",
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
width: themeSpacing(diameterUnits),
height: themeSpacing(diameterUnits),
borderRadius: "50%",
zIndex: 0,
marginInlineStart: count === 0 ? undefined : themeSpacing(-overlapUnits),
backgroundColor: (listener: Listener) => themeColor(listener, "inherit"),
color: (listener: Listener) => themeColor(listener, "shift-9"),
outline: (listener: Listener) => `${themeSpacing(1)} solid ${themeColor(listener, "inherit", ringColor)}`,
} as StyleObject,
};
}
/**
* A compact horizontal stack of overlapping circular avatars ending in a
* "+N" overflow badge. Static — no built-in animation. Call with no
* arguments for a working demo — 6 placeholder avatars plus a "+99" badge.
*/
function avatarCircles(props: AvatarCirclesProps = {}): DomphyElement<"div"> {
const avatars = props.avatars ?? defaultAvatars();
const overflowCount = props.overflowCount ?? DEFAULT_OVERFLOW_COUNT;
const diameterUnits = props.diameterUnits ?? DEFAULT_DIAMETER_UNITS;
const overlapUnits = props.overlapUnits ?? DEFAULT_OVERLAP_UNITS;
const ringColor = props.ringColor ?? "neutral";
const children: DomphyElement[] = avatars.map((item, index) =>
avatarLink(item, index, avatars.length, diameterUnits, overlapUnits, ringColor),
);
if (overflowCount > 0) {
children.push(overflowBadge(overflowCount, avatars.length, diameterUnits, overlapUnits, ringColor));
}
return {
div: children,
role: "group",
ariaLabel: `${avatars.length} members shown, plus ${overflowCount} more`,
style: {
display: "flex",
alignItems: "center",
width: "fit-content",
...(props.style ?? {}),
},
};
}
export { avatarCircles };