Overlapping avatars that spread on hover, lift individually with a name and role tooltip, show presence, fall back to initials and end in a +N count that can be a button.
248 people are working on this project
import { AvatarCircles } from "@/components/ballmac/avatar-circles"
const people = [
{ name: "Ada Lovelace", role: "Engineering", status: "online" as const },
{ name: "Grace Hopper", role: "Platform", status: "busy" as const },
{ name: "Katherine Johnson", role: "Data", status: "online" as const },
{ name: "Margaret Hamilton", role: "Design", status: "away" as const },
{ name: "Linus Torvalds", role: "Infrastructure" },
{ name: "Barbara Liskov", role: "Security" },
]
export default function AvatarCirclesDemo() {
return (
<div className="grid justify-items-center gap-3 pt-12">
<AvatarCircles people={people} max={5} total={248} size="lg" />
<p className="text-sm text-muted-foreground">248 people are working on this project</p>
</div>
)
}Installation
$ pnpm dlx shadcn@latest add @ballmac/avatar-circlesInstall the dependencies.
$ pnpm add motion@^12Add the Ballmac items it builds on.
$ pnpm dlx shadcn@latest add @ballmac/i18nCopy the source into your project.
components/ballmac/avatar-circles.tsx// Ballmac UI: Avatar Circles. https://ui.ballmac.com/components/avatar-circles "use client" import * as React from "react" import { motion, useReducedMotion } from "motion/react" import { cn } from "@/lib/utils" import { useMessages } from "@/lib/ballmac/i18n" type AvatarPerson = { /** Full name. Used for the tooltip and the accessible name. */ name: string /** Picture URL. Initials are shown when missing or when it fails to load. */ src?: string /** Second line in the tooltip, such as a role. */ role?: string /** Presence dot, shown with a word for assistive tech. */ status?: "online" | "busy" | "away" /** Makes the avatar a link. */ href?: string } const TONES = [ "bg-[color-mix(in_oklab,var(--chart-1)_62%,var(--background))]", "bg-[color-mix(in_oklab,var(--chart-2)_62%,var(--background))]", "bg-[color-mix(in_oklab,var(--chart-3)_62%,var(--background))]", "bg-[color-mix(in_oklab,var(--chart-4)_62%,var(--background))]", "bg-[color-mix(in_oklab,var(--chart-5)_62%,var(--background))]", ] const SIZES = { sm: { box: "size-8 text-[11px]", overlap: "-ms-2.5", spread: "group-hover/circles:-ms-1 group-focus-within/circles:-ms-1", dot: "size-2" }, default: { box: "size-10 text-xs", overlap: "-ms-3", spread: "group-hover/circles:-ms-1.5 group-focus-within/circles:-ms-1.5", dot: "size-2.5" }, lg: { box: "size-14 text-sm", overlap: "-ms-4", spread: "group-hover/circles:-ms-2 group-focus-within/circles:-ms-2", dot: "size-3" }, } const STATUS_DOT = { online: "bg-chart-2", busy: "bg-destructive", away: "bg-chart-3" } const STATUS_WORD = { online: "online", busy: "busy", away: "away" } function hue(text: string) { let h = 0 for (let i = 0; i < text.length; i++) h = (h * 31 + text.charCodeAt(i)) >>> 0 return h % TONES.length } function initials(name: string) { const words = name.trim().split(/\s+/) return ((words[0]?.[0] ?? "") + (words.length > 1 ? (words[words.length - 1]?.[0] ?? "") : "")).toUpperCase() } function Face({ person, size }: { person: AvatarPerson; size: keyof typeof SIZES }) { const [failed, setFailed] = React.useState(false) const s = SIZES[size] return ( <span className={cn( "relative flex shrink-0 items-center justify-center overflow-hidden rounded-full ring-2 ring-background font-semibold text-foreground", s.box, (!person.src || failed) && TONES[hue(person.name)] )} > {person.src && !failed ? ( // eslint-disable-next-line @next/next/no-img-element <img src={person.src} alt="" className="size-full object-cover" onError={() => setFailed(true)} /> ) : ( initials(person.name) )} </span> ) } type AvatarCirclesProps = Omit<React.ComponentProps<"div">, "children"> & { /** The people to show, first on the left. */ people: AvatarPerson[] /** How many faces show before the "+N" circle. */ max?: number /** Total number of people, when more than `people.length` (for example 1,200 members but 8 loaded). */ total?: number /** Face size. */ size?: keyof typeof SIZES /** Makes the "+N" circle a button. */ onOverflowClick?: () => void /** Accessible name of the whole group. */ label?: string } function AvatarCircles({ people, max = 5, total, size = "default", onOverflowClick, label, className, ...props }: AvatarCirclesProps) { const msg = useMessages() const reduce = useReducedMotion() const shown = people.slice(0, max) const extra = Math.max((total ?? people.length) - shown.length, 0) const s = SIZES[size] const summary = label ?? `${total ?? people.length} ${(total ?? people.length) === 1 ? "person" : "people"}` return ( <div data-slot="avatar-circles" role="group" aria-label={summary} className={cn("group/circles flex items-center", className)} {...props}> {shown.map((person, i) => { const name = person.status ? `${person.name}, ${STATUS_WORD[person.status]}` : person.name const inner = ( <> <Face person={person} size={size} /> {person.status && ( <span aria-hidden="true" className={cn("absolute end-0 bottom-0 z-10 rounded-full ring-2 ring-background", s.dot, STATUS_DOT[person.status])} /> )} <span aria-hidden="true" className="pointer-events-none absolute bottom-full left-1/2 z-30 mb-2 -translate-x-1/2 rounded-lg border bg-popover px-2.5 py-1.5 text-center text-xs whitespace-nowrap text-popover-foreground opacity-0 shadow-md transition-opacity duration-150 group-hover/face:opacity-100 group-focus-visible/face:opacity-100 motion-reduce:transition-none" > <span className="block font-medium">{person.name}</span> {person.role && <span className="block text-muted-foreground">{person.role}</span>} </span> </> ) const common = cn( "group/face relative inline-flex rounded-full outline-none transition-[margin] duration-200 focus-visible:z-20 focus-visible:ring-[3px] focus-visible:ring-ring/50 motion-reduce:transition-none", i > 0 && s.overlap, i > 0 && s.spread ) return ( <motion.span key={`${person.name}-${i}`} className="relative inline-flex" style={{ zIndex: shown.length - i }} whileHover={reduce ? undefined : { y: -4, scale: 1.1, zIndex: 40 }} whileFocus={reduce ? undefined : { y: -4, scale: 1.1, zIndex: 40 }} transition={{ type: "spring", stiffness: 500, damping: 28 }} > {person.href ? ( <a href={person.href} aria-label={name} className={common}> {inner} </a> ) : ( <button type="button" aria-label={name} className={common}> {inner} </button> )} </motion.span> ) })} {extra > 0 && (onOverflowClick ? ( <button type="button" onClick={onOverflowClick} aria-label={msg("avatar-circles.andMore", "and {extra} more", { extra })} className={cn("relative z-0 flex shrink-0 items-center justify-center rounded-full bg-muted font-semibold text-foreground ring-2 ring-background outline-none transition-[margin,background-color] duration-200 hover:bg-accent focus-visible:ring-[3px] focus-visible:ring-ring/50", s.box, s.overlap, s.spread)} > +{extra} </button> ) : ( <span role="img" aria-label={msg("avatar-circles.andMore", "and {extra} more", { extra })} className={cn("relative z-0 flex shrink-0 items-center justify-center rounded-full bg-muted font-semibold text-foreground ring-2 ring-background transition-[margin] duration-200", s.box, s.overlap, s.spread)} > +{extra} </span> ))} </div> ) } export { AvatarCircles, type AvatarCirclesProps, type AvatarPerson }Update the import paths to match your project setup.
Usage
import { AvatarCircles } from "@/components/ballmac/avatar-circles"The full example is in the Code tab above.
Examples
Sizes and overflow button
"use client"
import * as React from "react"
import { AvatarCircles } from "@/components/ballmac/avatar-circles"
const people = ["Ada", "Grace", "Linus", "Katherine", "Margaret", "Barbara", "Dennis"].map((name) => ({ name, href: "#" }))
export default function AvatarCirclesSizes() {
const [clicks, setClicks] = React.useState(0)
return (
<div className="grid justify-items-center gap-5 pt-10">
<AvatarCircles size="sm" people={people} max={4} />
<AvatarCircles size="default" people={people} max={4} onOverflowClick={() => setClicks((c) => c + 1)} />
<p className="text-xs text-muted-foreground" aria-live="polite">{clicks ? `Overflow opened ${clicks} time${clicks === 1 ? "" : "s"}` : "The +3 is a button."}</p>
</div>
)
}API reference
| Prop | Type | Default |
|---|---|---|
people*The people to show, first on the left. | AvatarPerson[] | — |
maxHow many faces show before the "+N" circle. | number | 5 |
totalTotal number of people, when more than `people.length` (for example 1,200 members but 8 loaded). | number | — |
sizeFace size. | keyof typeof SIZES | "default" |
onOverflowClickMakes the "+N" circle a button. | () => void | — |
labelAccessible name of the whole group. | string | — |
Also accepts the standard attributes of its root element.
Accessibility
| Key | Action |
|---|---|
| Tab | Each person is focusable and shows the tooltip on focus |
| Screen readers | Named 'Ada Lovelace, online'; the group announces the people count |
| Reduced motion | No lift; the spread still happens with a plain transition |
Use with AI
<AvatarCircles people={[{ name, src, role, status, href }]} max total size onOverflowClick />. Each face is a link or button named with its person (and status). total adds the +N when only some are loaded. With the shadcn MCP server set up (guide), ask your agent:
Add the Ballmac UI Avatar Circles (@ballmac/avatar-circles) to this project with the shadcn MCP, then use it where it fits.
Use it for
- Showing who is in a project, document or room
- Social proof next to a call to action
Not for
- A plain compact row (avatar-stack)
- Large member tables (data-table)
Registry JSON: https://ui.ballmac.com/r/avatar-circles.json
Credits
Free to use in personal and commercial projects.
- npm
- motion@^12
- Registry
- @ballmac/i18nshadcn/utils
Pairs well with
Avatar Stack
An accessible compact row of people with initials, image support, and an overflow count.
Avatar
A Radix avatar with image and initials fallback, three sizes, an optional presence dot, and AvatarGroup for overlapping stacks with a +N overflow counter.
Tooltip
A Radix tooltip with an arrow, side offset and fade and zoom transitions that opens on hover and keyboard focus. Works standalone or under a shared provider.
Activity Feed
A compact actor and action feed with timestamps and an empty state.