EmpireUI
Get Pro
← Blog8 min read#tailwind#avatar#group

Avatar and Avatar Group in Tailwind: Initials, Stack, Count Badge

Build stacked avatar groups with Tailwind CSS — initials fallback, overlap rings, and count badges. No library needed, just utility classes and React.

Code editor screen showing React component UI development

What We're Actually Building

An avatar group sounds trivial. Circles with faces, stacked on top of each other, maybe a "+3" badge at the end. How hard can it be? Turns out, once you get into image fallbacks, overlap rings, responsive sizing, and accessible markup, there's more surface area than you'd expect.

This guide walks you through a production-ready Avatar component and an AvatarGroup wrapper — both built with pure Tailwind CSS and React, no external UI library. You'll handle the three states every avatar needs: image loaded, image broken (show initials), and image still loading (show a skeleton pulse).

Worth noting: Tailwind v3.4 introduced the ring-offset-background trick that makes overlapping ring borders actually work without a white flash on dark backgrounds. We're using that here, so make sure you're on at least v3.4.0.

If you want to skip ahead and grab pre-built avatar components for your project, the Empire UI component library has several stacking patterns ready to copy.

Single Avatar: Image, Initials, and Loading State

Start with the base Avatar component. It needs to handle three outcomes gracefully — image success, image error, and the moment before either happens. Honestly, most avatar implementations skip the loading state entirely and you get a jarring flash of broken layout when images are slow.

Here's a clean single avatar with all three states covered: ``tsx import { useState } from 'react' type AvatarProps = { src?: string alt: string name: string size?: 'sm' | 'md' | 'lg' } const sizes = { sm: 'w-8 h-8 text-xs', md: 'w-10 h-10 text-sm', lg: 'w-12 h-12 text-base', } function getInitials(name: string) { return name .split(' ') .slice(0, 2) .map((n) => n[0]) .join('') .toUpperCase() } export function Avatar({ src, alt, name, size = 'md' }: AvatarProps) { const [status, setStatus] = useState<'loading' | 'loaded' | 'error'>( src ? 'loading' : 'error' ) return ( <span className={ relative inline-flex items-center justify-center rounded-full overflow-hidden bg-indigo-600 font-semibold text-white select-none shrink-0 ${sizes[size]} } > {status === 'loading' && ( <span className="absolute inset-0 animate-pulse bg-slate-700" /> )} {src && status !== 'error' && ( <img src={src} alt={alt} className={absolute inset-0 w-full h-full object-cover transition-opacity duration-200 ${ status === 'loaded' ? 'opacity-100' : 'opacity-0' }} onLoad={() => setStatus('loaded')} onError={() => setStatus('error')} /> )} {(status === 'error' || !src) && ( <span aria-hidden="true">{getInitials(name)}</span> )} <span className="sr-only">{name}</span> </span> ) } ``

The getInitials helper grabs the first letter of the first two words in a name — so "Jane Doe" becomes "JD" and "Alice" becomes "A". Dead simple, covers both single-name and full-name users.

Quick aside: the select-none class prevents the initials from being selectable text, which looks weird when users drag across a list. Small detail, but noticeable.

In practice, you'll also want to vary the background color by user so all initials avatars don't look identical. A simple hash of the name string mapped to a set of 8 Tailwind color classes works well — bg-rose-600, bg-amber-600, bg-emerald-600, etc.

Stacking Avatars: The Ring Overlap Trick

The stacked look — where each avatar slightly overlaps the previous one — requires a negative margin and a ring border to visually separate them. The tricky part is that ring borders on dark backgrounds show a color mismatch unless you tell Tailwind what background color the ring is offset against.

Here's the overlap pattern that actually works: ``tsx // In your globals or tailwind.config.js: // ring-offset-background relies on CSS var --background being set // If you're using a plain white bg, ring-offset-white works fine export function AvatarStack({ avatars, max = 4, }: { avatars: AvatarProps[] max?: number }) { const visible = avatars.slice(0, max) const overflow = avatars.length - max return ( <div className="flex items-center" aria-label={${avatars.length} members}> {visible.map((av, i) => ( <span key={av.name} className={ -ml-2 first:ml-0 ring-2 ring-white dark:ring-zinc-900 rounded-full relative z-[${10 - i}] } title={av.name} > <Avatar {...av} size="md" /> </span> ))} {overflow > 0 && ( <span className=" -ml-2 inline-flex items-center justify-center w-10 h-10 rounded-full bg-slate-200 dark:bg-zinc-700 text-xs font-semibold text-slate-700 dark:text-zinc-200 ring-2 ring-white dark:ring-zinc-900 " aria-label={${overflow} more members} > +{overflow} </span> )} </div> ) } ``

The -ml-2 (which is -8px) on every item after the first creates the overlap. Combined with a ring-2 in the same color as your page background, you get a clear visible gap between avatars even when their colors are similar.

One more thing — the z-[${10 - i}] stacking makes the leftmost avatar sit on top. Without this, the last avatar in the list overlaps the first, which looks backwards to users who expect the primary person to be frontmost.

For the +N badge, 40px width matches the md avatar size exactly. If you add a sm or lg size prop to AvatarStack, just make sure the badge dimensions stay in sync with the avatar size variant you're rendering.

You can preview different stacking configurations — including those with glassmorphism card backdrops — using Empire UI's glassmorphism components to see how ring colors interact with blurred backgrounds.

The Count Badge: When to Show "+N" vs a Real Number

The count badge at the end of a stack is one of those details that seems obvious until you start thinking about edge cases. What if there are 0 overflow users? What if there are 100? What if the stack is inside a very narrow container?

In practice, three rules cover most product scenarios. First, only render the badge when overflow > 0 — no "+0" ghost badges. Second, cap the displayed number at 99, showing "99+" for anything above that. Third, when the list has exactly max + 1 items, don't show "+1" — just show the extra avatar instead. Nobody wants to click a badge to see one more person. ``tsx function CountBadge({ count, size }: { count: number; size: 'sm' | 'md' | 'lg' }) { if (count <= 0) return null const label = count > 99 ? '99+' : +${count} const sizeClasses = { sm: 'w-8 h-8 text-xs', md: 'w-10 h-10 text-xs', lg: 'w-12 h-12 text-sm', } return ( <span className={ -ml-2 inline-flex items-center justify-center rounded-full bg-slate-100 dark:bg-zinc-800 text-slate-600 dark:text-zinc-300 font-semibold ring-2 ring-white dark:ring-zinc-900 ${sizeClasses[size]} } aria-label={${count} more} > {label} </span> ) } ``

Look, the "show the extra avatar when overflow is 1" rule matters more than you think. Users instinctively try to read all the faces — cutting off to show "+1" when you could just show the face feels like a missed opportunity and makes the truncation feel arbitrary.

Worth noting: if your avatar group is inside a tooltip trigger or a popover, you'd open a list of all members when a user clicks the count badge. That's a separate concern — the component above just handles the display layer. Wire the click handler separately based on your app's state management.

Sizing System and Responsive Variants

Three sizes — sm (32px), md (40px), lg (48px) — cover the vast majority of use cases. Header nav shows sm, comment threads use md, profile cards use lg. That said, you might need to swap sizes at different breakpoints.

Tailwind's responsive prefix makes this clean: ``tsx // Responsive AvatarGroup that shifts sizes at breakpoints export function AvatarGroup({ avatars, max = 4, }: { avatars: AvatarProps[] max?: number }) { // At mobile: show max 3, size sm // At md+: show full max, size md const isMobileMax = 3 const visibleMobile = avatars.slice(0, isMobileMax) const visibleDesktop = avatars.slice(0, max) const overflowMobile = avatars.length - isMobileMax const overflowDesktop = avatars.length - max return ( <> {/* Mobile layout — hidden on md+ */} <div className="flex items-center md:hidden"> {visibleMobile.map((av) => ( <span key={av.name} className="-ml-1.5 first:ml-0 ring-2 ring-white rounded-full"> <Avatar {...av} size="sm" /> </span> ))} <CountBadge count={overflowMobile} size="sm" /> </div> {/* Desktop layout — hidden below md */} <div className="hidden md:flex items-center"> {visibleDesktop.map((av) => ( <span key={av.name} className="-ml-2 first:ml-0 ring-2 ring-white dark:ring-zinc-900 rounded-full"> <Avatar {...av} size="md" /> </span> ))} <CountBadge count={overflowDesktop} size="md" /> </div> </> ) } ``

Is this overkill for a simple avatar group? Maybe. But if you're building a collaborative tool where the avatar group appears in tight sidebars and also in wide dashboard cards, you'll thank yourself for having this flexibility baked in from day one.

One more thing — the overlap margin also needs to scale with avatar size. Use -ml-1.5 for sm (6px overlap on 32px circles) and -ml-2 for md/lg (8px overlap). The ratio should be around 20-25% of the avatar diameter to look natural.

Check out the box shadow generator if you want to add a subtle drop shadow under the stack instead of a ring border — sometimes that reads better on complex backgrounds than a hard ring outline.

Accessibility: What Screen Readers Actually Need

Avatar groups are a spot where accessibility often gets punted. The visual information is intuitive — you see faces, you understand a group — but screen readers need explicit labeling or they'll just announce a list of unnamed images.

The minimum requirements are: every <img> gets a meaningful alt (the person's name), the group wrapper gets an aria-label with the total count, and the overflow badge gets its own aria-label like "5 more members". That's it. Here's what the full accessible markup looks like once assembled: ``tsx // Fully accessible AvatarGroup usage <AvatarStack avatars={[ { src: '/avatars/alice.jpg', alt: 'Alice Chen', name: 'Alice Chen' }, { src: '/avatars/bob.jpg', alt: 'Bob Smith', name: 'Bob Smith' }, { name: 'Clara Diaz' }, // no src — will show initials { src: '/avatars/dan.jpg', alt: 'Dan Park', name: 'Dan Park' }, { src: '/avatars/ella.jpg', alt: 'Ella Torres', name: 'Ella Torres' }, ]} max={3} /> // Screen reader output: // "4 members — Alice Chen, Bob Smith, Clara Diaz, 2 more members" ``

Honestly, the title attribute on each wrapper <span> also gives sighted keyboard users a tooltip on hover — a small touch that helps when names aren't displayed next to the avatars. Don't skip it.

If you're building something more interactive — say, clicking an avatar opens a profile card — wrap each avatar in a <button> with aria-label="View profile for {name}" rather than an anchor. Buttons are for actions, links are for navigation. Don't confuse the two.

For broader React accessibility patterns, the react-accessibility-guide covers focus management and ARIA patterns that pair well with interactive avatar groups.

Putting It Together: A Full Usage Example

Here's the complete wiring in a realistic context — a project card showing who's collaborating on a task, with a tooltip on the count badge to list remaining names: ``tsx import { Avatar, CountBadge } from '@/components/Avatar' import * as Tooltip from '@radix-ui/react-tooltip' const collaborators = [ { src: '/u/maya.jpg', alt: 'Maya Lin', name: 'Maya Lin' }, { src: '/u/kai.jpg', alt: 'Kai Osei', name: 'Kai Osei' }, { name: 'Priya Nair' }, { src: '/u/james.jpg', alt: 'James Ford', name: 'James Ford' }, { src: '/u/sora.jpg', alt: 'Sora Kim', name: 'Sora Kim' }, { name: 'Leo Garcia' }, ] const MAX_VISIBLE = 4 const visible = collaborators.slice(0, MAX_VISIBLE) const overflow = collaborators.slice(MAX_VISIBLE) export function ProjectCard() { return ( <div className="rounded-xl border border-zinc-200 dark:border-zinc-800 p-4 flex items-center justify-between"> <p className="font-medium text-zinc-900 dark:text-zinc-100">Design Sprint</p> <div className="flex items-center" aria-label={${collaborators.length} collaborators}> {visible.map((c, i) => ( <span key={c.name} className={-ml-2 first:ml-0 ring-2 ring-white dark:ring-zinc-900 rounded-full z-${10 - i}} title={c.name} > <Avatar {...c} size="sm" /> </span> ))} {overflow.length > 0 && ( <Tooltip.Root> <Tooltip.Trigger asChild> <span> <CountBadge count={overflow.length} size="sm" /> </span> </Tooltip.Trigger> <Tooltip.Content className="rounded-md bg-zinc-900 text-white text-xs px-2 py-1.5"> {overflow.map((c) => c.name).join(', ')} </Tooltip.Content> </Tooltip.Root> )} </div> </div> ) } ``

The Radix UI Tooltip wrapping the count badge is a common pattern — clicking or hovering reveals all the names that didn't fit. Zero dependencies beyond what most React projects already use.

That said, if Radix isn't in your stack, a CSS-only tooltip using group and group-hover utilities in Tailwind works fine for basic cases. Just don't use title attributes alone — they don't fire on touch devices and have inconsistent timing across browsers.

For more Tailwind component patterns like this, browse the tailwind-component-patterns article — it covers several compound component patterns that pair naturally with avatar groups.

One more thing — if your design system uses a glassmorphism card style for project cards, the avatar ring colors need to match the card backdrop. Try using ring-white/20 with backdrop-blur instead of a solid ring color, and test it against your glassmorphism generator output to get the right opacity.

FAQ

How do I change the background color of initials avatars per user?

Hash the user's name or ID to an index, then pick from a predefined array of Tailwind background classes like ['bg-rose-600', 'bg-amber-600', 'bg-indigo-600']. Apply the class conditionally — no inline styles needed.

Why does the ring border show a white flash between avatars on dark mode?

The ring-offset color defaults to white. Override it with dark:ring-zinc-900 (or whatever your dark background color is) on the wrapping span. Tailwind v3.4+ makes this straightforward with the ring-offset-background variable pattern.

Can I animate new avatars sliding in when the list updates?

Yes — wrap each avatar span with Framer Motion's motion.span and add layout plus an initial={{ x: -8, opacity: 0 }} / animate={{ x: 0, opacity: 1 }} transition. The layout prop handles the reflow of existing items automatically.

What's the right accessible role for an avatar group?

Use a plain <div> or <span> with aria-label describing the group (e.g. "5 project members"). Individual images get alt with the person's name. No need for role="list" unless you're explicitly rendering list semantics with <ul> and <li>.

Free components in 40 styles
React & Tailwind, copy-paste ready.
Browse →

Read next

Building a Landing Page in Tailwind CSS: Section by SectionE-Commerce Product Page in Tailwind: Gallery, Options, CTAAvatar Component in React: Initials Fallback, Status Badge, GroupFooter Design in React: 5 Patterns From Minimal to Full-Featured