Avatar Stack
Overlapping avatars that fan apart on hover, with presence dots, tooltips and a +N overflow list.
npx shadcn@latest add https://sundaeui.dev/r/avatar-stack.json'use client'
import { useId, useState } from 'react'
export interface Person {
id: string
name: string
/** Image URL. Falls back to initials on a deterministic colour. */
src?: string
status?: 'online' | 'away' | 'offline'
role?: string
}
export interface AvatarStackProps {
people: Person[]
/** Visible avatars before the +N overflow. */
max?: number
size?: number
label?: string
}
const HUES = [12, 32, 150, 190, 215, 265, 330]
const hue = (s: string) => HUES[[...s].reduce((a, c) => a + c.charCodeAt(0), 0) % HUES.length]
const initials = (n: string) => n.split(/\s+/).map((p) => p[0]).slice(0, 2).join('').toUpperCase()
function Avatar({ p, size }: { p: Person; size: number }) {
const [broken, setBroken] = useState(false)
return (
<span className="relative block rounded-full ring-2 ring-white dark:ring-zinc-950" style={{ width: size, height: size }}>
{p.src && !broken ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={p.src} alt="" onError={() => setBroken(true)} className="size-full rounded-full object-cover" />
) : (
<span className="grid size-full place-items-center rounded-full font-semibold text-white" style={{ background: `oklch(0.62 0.13 ${hue(p.name)})`, fontSize: size * 0.38 }}>
{initials(p.name)}
</span>
)}
{p.status && <span aria-hidden className={`absolute bottom-0 right-0 rounded-full ring-2 ring-white dark:ring-zinc-950 ${p.status === 'online' ? 'bg-emerald-500' : p.status === 'away' ? 'bg-amber-400' : 'bg-zinc-400'}`} style={{ width: size * 0.28, height: size * 0.28 }} />}
</span>
)
}
/** Overlapping avatars that fan apart on hover, with tooltips and an overflow list. */
export function AvatarStack({ people, max = 4, size = 36, label = 'Collaborators' }: AvatarStackProps) {
const id = useId()
const [tip, setTip] = useState<string | null>(null)
const [more, setMore] = useState(false)
const shown = people.slice(0, max)
const rest = people.slice(max)
return (
<div className="relative inline-flex items-center text-zinc-900 dark:text-zinc-100" role="group" aria-label={`${label}: ${people.length}`}>
<ul className="group/stack flex items-center" onPointerLeave={() => setTip(null)}>
{shown.map((p, i) => (
<li key={p.id} className="relative transition-[margin] duration-300 ease-[cubic-bezier(.22,1,.36,1)] group-hover/stack:ml-1 first:!ml-0" style={{ marginLeft: i ? -size * 0.3 : 0, zIndex: shown.length - i }}>
<button
type="button"
aria-describedby={tip === p.id ? `${id}-tip` : undefined}
aria-label={`${p.name}${p.status ? `, ${p.status}` : ''}`}
onPointerEnter={() => setTip(p.id)}
onFocus={() => setTip(p.id)}
onBlur={() => setTip(null)}
className="block rounded-full transition-transform duration-200 hover:-translate-y-0.5 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-zinc-900 dark:focus-visible:outline-white"
>
<Avatar p={p} size={size} />
</button>
{tip === p.id && (
<span id={`${id}-tip`} role="tooltip" className="pointer-events-none absolute bottom-full left-1/2 z-20 mb-2 -translate-x-1/2 whitespace-nowrap rounded-lg bg-zinc-900 px-2.5 py-1.5 text-[12px] text-white shadow-lg motion-safe:animate-[av-tip_160ms_ease-out] dark:bg-white dark:text-zinc-900">
<span className="font-medium">{p.name}</span>
{p.role && <span className="ml-1.5 opacity-60">{p.role}</span>}
</span>
)}
</li>
))}
{rest.length > 0 && (
<li className="relative" style={{ marginLeft: -size * 0.3 }}>
<button
type="button"
aria-expanded={more}
aria-controls={`${id}-more`}
onClick={() => setMore((m) => !m)}
className="grid place-items-center rounded-full bg-zinc-100 text-[12px] font-semibold text-zinc-600 ring-2 ring-white transition hover:bg-zinc-200 dark:bg-zinc-800 dark:text-zinc-300 dark:ring-zinc-950"
style={{ width: size, height: size }}
aria-label={`${rest.length} more`}
>
+{rest.length}
</button>
{more && (
<ul id={`${id}-more`} className="absolute left-0 top-full z-20 mt-2 w-56 rounded-xl border border-zinc-200 bg-white p-1.5 shadow-[0_20px_50px_-20px_rgb(0_0_0/0.3)] motion-safe:animate-[av-tip_160ms_ease-out] dark:border-zinc-800 dark:bg-zinc-900">
{rest.map((p) => (
<li key={p.id} className="flex items-center gap-2.5 rounded-lg px-2 py-1.5">
<Avatar p={p} size={26} />
<span className="min-w-0 flex-1">
<span className="block truncate text-[13px] font-medium">{p.name}</span>
{p.role && <span className="block truncate text-[11.5px] text-zinc-500">{p.role}</span>}
</span>
</li>
))}
</ul>
)}
</li>
)}
</ul>
<style>{`@keyframes av-tip{from{opacity:0;transform:translate(-50%,3px)}}`}</style>
</div>
)
}Overview
Shows who is here without taking much room. Avatars overlap with a ring that separates them on any background. Hover the group and they ease apart, so each face and its tooltip are easy to target. Presence dots show online, away or offline.
Without an image, a person gets initials on a colour derived from their name, so it is stable between renders and sessions. Broken image URLs fall back to initials too. Past max, a +N button opens a list of everyone else.
Use it for collaborators, assignees and team previews.
When to use
- A single interaction you want to feel finished, dropped into an existing screen.
When not to use
- A whole page layout: look at Sections or Templates.
Installation
pnpm dlx shadcn@latest add https://sundaeui.dev/r/avatar-stack.jsonnpx shadcn@latest add https://sundaeui.dev/r/avatar-stack.jsonyarn dlx shadcn@latest add https://sundaeui.dev/r/avatar-stack.jsonbunx --bun shadcn@latest add https://sundaeui.dev/r/avatar-stack.jsonWorks in any React project. Files are written to components/sundae/. Read the source in the stage’s Code view.
- No dependencies beyond React 18 or later.
- Copy avatar-stack.tsx into components/sundae/.
- Make sure Tailwind scans that folder (v4 detects it automatically; v3 needs it in `content`).
- Import the component and use it as shown below.
Usage
import { AvatarStack } from '@/components/sundae/avatar-stack'
<AvatarStack max={4} people={[{ id: '1', name: 'Ines Duarte', src: '/ines.jpg', status: 'online', role: 'Design' }]} />Props
| Prop | Type | Default |
|---|---|---|
people*{ id, name, src?, status?, role? }. | Person[] | — |
maxVisible avatars before overflow. | number | 4 |
sizeDiameter in px. | number | 36 |
labelGroup label (count is appended). | string | 'Collaborators' |
Accessibility
- Each avatar is a button labelled with the name and status. Tooltips show on focus as well as hover.
- The overflow button exposes aria-expanded and controls the list.
Customization
- Change HUES to keep initials colours on-brand.
FAQ
- Is Avatar Stack free?
- Yes. Avatar Stack is free for personal and commercial projects, with full source on this page.
- How do I install Avatar Stack?
- Run npx shadcn@latest add https://sundaeui.dev/r/avatar-stack.json. The files are written to components/sundae/. It has no dependencies beyond React 18 or later.
- Does Avatar Stack support keyboard and screen readers?
- Each avatar is a button labelled with the name and status. Tooltips show on focus as well as hover. The overflow button exposes aria-expanded and controls the list.
- Does Avatar Stack respect reduced motion?
- Yes. With prefers-reduced-motion set, decorative motion is removed and state changes are instant.
More like this
All components →- Pro
Sheet Drawer
Components · Refined - Pro
Notification Center
Components · Refined Fold Accordion
Components · UnderstatedToast Stack
Components · RefinedConfirm Dialog
Components · Understated- Pro
Team Members
Components · Understated