Glassmorphism Avatar Group: User List with Stacked Design
Build a glassmorphism avatar group with stacked overlapping avatars, frosted-glass tooltips, and Tailwind v4 utility classes. Real code, zero fluff.
Why Glassmorphism Works So Well for Avatar Groups
Honestly, the stacked avatar group is one of those UI patterns that looks deceptively simple but gets ruined by bad implementation all the time. You've seen it — tiny profile pictures crammed together with a harsh white border, no depth, no breathing room. It looks like a 2015 admin panel.
Glassmorphism changes that equation completely. When you wrap overlapping avatars in a frosted-glass container — think backdrop-filter: blur(12px) paired with background: rgba(255,255,255,0.1) — you get a sense of depth that makes the stacking feel intentional rather than accidental. The translucency lets the background bleed through just enough to anchor the component in its context.
This article walks through building a production-ready glassmorphism avatar group in React with Tailwind v4.0.2. We'll cover the stacking logic, the overflow count badge, hover tooltips with frosted backgrounds, and the subtle ring treatment that separates each avatar visually. If you've been curious about what glassmorphism actually is from a technical standpoint, that's a good primer before digging in here.
The Stacking Mechanic: Negative Margin and Z-Index Logic
The visual trick behind any avatar group is negative margin. Each avatar after the first gets pulled left by a fixed amount — usually somewhere between -8px and -16px depending on avatar size. With 40px avatars, -ml-3 (−12px in Tailwind) tends to hit the sweet spot. Too much overlap and you can't distinguish faces. Too little and it just looks like a row of avatars with tight spacing.
Z-index ordering matters more than people expect here. You want the leftmost avatar on top, not the rightmost. That means reversing the natural stacking order by applying z-10 to the first item, z-[9] to the second, and so on — or you can use flex-row-reverse on the container and flip the visual order with direction: rtl. The flex-reverse approach is cleaner because you don't have to manually track z-index per item.
One thing worth noting: if you're using Tailwind v4.0.2's arbitrary value syntax for z-index like z-[9], make sure your content security policy isn't stripping inline styles. The compiled output is fine, but some teams get bitten by this in production.
Building the Glass Ring Border Effect
Every avatar in the stack needs a visible ring to separate it from the one behind it. A naive approach is border-2 border-white, which works but looks flat. The glassmorphism treatment calls for a semi-transparent ring — rgba(255,255,255,0.35) — so the background bleeds through the border itself.
In Tailwind you can't express arbitrary RGBA border colors cleanly without a custom property, so this is one place where a CSS variable on the component wrapper pays off. Set --avatar-ring: rgba(255,255,255,0.35) on the parent and reference it with ring-2 ring-[var(--avatar-ring)]. Tailwind v4.0.2 handles this without needing a plugin.
Here's the full ring-plus-blur treatment for a single avatar inside the group:
function GlassAvatar({ src, name, size = 40 }: AvatarProps) {
return (
<div
className="relative rounded-full"
style={{
width: size,
height: size,
// Semi-transparent ring — the glassmorphism touch
boxShadow: '0 0 0 2px rgba(255,255,255,0.35)',
backdropFilter: 'blur(4px)',
WebkitBackdropFilter: 'blur(4px)',
}}
>
<img
src={src}
alt={name}
className="w-full h-full rounded-full object-cover"
/>
</div>
);
}Full Avatar Group Component with Overflow Count Badge
Once you've got the individual avatar sorted, the group component needs two things: the stacking layout and an overflow badge that shows +N when users exceed the max display count. The badge should match the glass aesthetic — not a solid colored circle, but a frosted one.
Here's a complete, self-contained implementation. This uses Tailwind v4.0.2 class names and works with any React 18+ setup:
import React from 'react';
interface User {
id: string;
name: string;
avatar: string;
}
interface AvatarGroupProps {
users: User[];
max?: number;
size?: number;
overlap?: number;
}
export function GlassmorphismAvatarGroup({
users,
max = 4,
size = 40,
overlap = 12,
}: AvatarGroupProps) {
const visible = users.slice(0, max);
const overflowCount = users.length - max;
return (
<div
className="flex items-center"
style={{ '--overlap': `-${overlap}px` } as React.CSSProperties}
>
{visible.map((user, index) => (
<div
key={user.id}
className="relative group"
style={{
marginLeft: index === 0 ? 0 : -overlap,
zIndex: max - index,
}}
>
{/* Avatar */}
<div
className="rounded-full overflow-hidden"
style={{
width: size,
height: size,
boxShadow: '0 0 0 2px rgba(255,255,255,0.35), 0 4px 12px rgba(0,0,0,0.2)',
backdropFilter: 'blur(4px)',
WebkitBackdropFilter: 'blur(4px)',
}}
>
<img
src={user.avatar}
alt={user.name}
className="w-full h-full object-cover"
/>
</div>
{/* Frosted glass tooltip on hover */}
<div
className="
absolute -top-10 left-1/2 -translate-x-1/2
px-2 py-1 rounded-md text-xs text-white whitespace-nowrap
opacity-0 group-hover:opacity-100 transition-opacity duration-200
pointer-events-none
"
style={{
background: 'rgba(255,255,255,0.15)',
backdropFilter: 'blur(8px)',
WebkitBackdropFilter: 'blur(8px)',
border: '1px solid rgba(255,255,255,0.2)',
boxShadow: '0 2px 8px rgba(0,0,0,0.15)',
}}
>
{user.name}
</div>
</div>
))}
{/* Overflow badge */}
{overflowCount > 0 && (
<div
className="relative flex items-center justify-center rounded-full text-xs font-semibold text-white"
style={{
width: size,
height: size,
marginLeft: -overlap,
zIndex: 0,
background: 'rgba(255,255,255,0.15)',
backdropFilter: 'blur(8px)',
WebkitBackdropFilter: 'blur(8px)',
boxShadow: '0 0 0 2px rgba(255,255,255,0.35), 0 4px 12px rgba(0,0,0,0.2)',
}}
>
+{overflowCount}
</div>
)}
</div>
);
}The tooltip uses rgba(255,255,255,0.15) background with blur(8px) — that's the frosted glass recipe. You could swap the inline styles for Tailwind's backdrop-blur-sm and bg-white/15 if you prefer utility-only, but the inline approach gives you a cleaner diff to review.
Dark Mode Considerations for Glass Avatars
Glass UI is tricky in dark mode because the rgba(255,255,255,0.1) tint that looks great on a dark background becomes invisible on a light one. For the avatar group, the ring is the main concern — rgba(255,255,255,0.35) won't show up on a white page at all.
The cleanest solution is CSS custom properties toggled by a dark class on your root. In dark mode, keep the white-tint ring. In light mode, swap to rgba(0,0,0,0.12). You can use Empire UI's built-in theme toggle component to handle the class toggling — it's already wired for this pattern. The avatar component itself just needs to read from the token.
Don't hardcode colors in the component. If you're building for a product that might ship in both modes, you'll thank yourself later for keeping everything as CSS variables. Refactoring color values out of 12 separate boxShadow strings at 2am is not fun.
When to Use Stacked vs. Inline vs. Grid Avatar Layouts
The stacked avatar group makes sense for "who's currently here" contexts — online indicators, document collaborators, team membership previews. It's a compact way to show social proof without dedicating a whole section to it. Think Figma's multiplayer bar or Notion's sharing panel.
Inline layouts (where avatars sit side by side with an 8px gap rather than overlapping) work better when you need users to be individually clickable — each avatar is its own target. Stacking reduces individual touch targets, so on mobile interfaces you either need to increase avatar size to at least 48px or switch to the inline variant below a breakpoint.
Grid layouts are a different animal entirely. They're for user directories, team pages, or follower grids where you're showing potentially dozens of faces. For those cases, glassmorphism can still apply — frosted card backgrounds, glassy hover states — but the stacking mechanic doesn't translate. If you're curious how glassmorphism compares to other design styles that solve similar visual problems, glassmorphism vs neumorphism covers the tradeoffs in depth.
Accessibility: What Glass Avatars Get Wrong by Default
Here's the thing: most glassmorphism avatar groups fail accessibility before anyone checks a contrast ratio. The alt text on avatar images is often blank or set to the filename. Screen readers get img elements with no meaningful description. That's fixable — just use the user's name as the alt value, which is what the component above already does.
The overflow badge (+N) is the second problem. It's rendered as a div with no semantic meaning. A screen reader will either skip it or read it as an unlabeled element. Add aria-label={${overflowCount} more users} to that element. Twelve characters that make the component usable for everyone.
Keyboard focus is the third gap. The tooltip currently only triggers on hover via group-hover. That means keyboard users tabbing through the page get no tooltip content. Swap group-hover:opacity-100 to group-hover:opacity-100 group-focus-within:opacity-100 and add tabIndex={0} to the avatar wrapper. Now the tooltip shows on focus too. It's worth also looking at how best free glassmorphism components handle accessibility patterns if you want to see what the community has standardized on.
Performance: backdrop-filter Cost and When to Disable It
Let's talk about what backdrop-filter: blur() actually costs. It triggers a compositing layer for every element it's applied to. On a page with four overlapping avatars, you're potentially creating four separate compositor layers. On most modern GPUs, this is basically free. On low-end Android devices or older iPads, you'll see jank.
The pragmatic solution is to apply the blur only on the container that wraps all the avatars, not on each individual avatar. Move the backdrop-filter: blur(12px) to the parent div and use background: rgba(255,255,255,0.1) on the individual avatars without blur. You lose a bit of the per-avatar depth effect but cut compositor layers from N to 1.
You can also conditionally disable backdrop-filter based on a prefers-reduced-motion or a custom low-performance hook. Some teams use window.navigator.hardwareConcurrency < 4 as a rough proxy for underpowered devices — not accurate, but better than applying blur unconditionally. Use @media (prefers-reduced-transparency) in CSS if you want a standards-based fallback.
FAQ
Replace the white-tint ring (rgba(255,255,255,0.35)) with a dark-tint version (rgba(0,0,0,0.12)) when not in dark mode. Use a CSS custom property on the root — --avatar-ring-color — and toggle it with your dark mode class. The backdrop-blur itself is background-agnostic, so that stays the same.
For 40px avatars, -12px (Tailwind's -ml-3) gives good separation. For 32px avatars, try -8px. For 48px, -14px to -16px. The rule of thumb is 25-30% of avatar diameter. Going beyond 35% makes it hard to distinguish individual avatars.
Yes, but you need -webkit-backdrop-filter as well as backdrop-filter. Safari has supported prefixed backdrop-filter since Safari 9. The unprefixed version landed in Safari 18. Always include both — the component code above already does this.
Replace the hover tooltip div with a popover component, and add an onClick handler to the avatar wrapper. Track activeUserId in state. The key thing is to set position: relative on a parent element so the popover positions correctly relative to the clicked avatar, not the document root.
Yes — wrap each avatar in AnimatePresence from Framer Motion and give each a unique key (the user id). Entering avatars can scale from 0.5 to 1 with an opacity fade. Leaving avatars can shrink and fade out. The negative margin layout handles the reflow automatically as items enter and exit the DOM.
The badge needs the same boxShadow ring value as the avatars — 0 0 0 2px rgba(255,255,255,0.35). It's easy to forget since the badge is a plain div rather than an img wrapper. Also make sure it gets the same backdrop-filter and WebkitBackdropFilter values so the frosted appearance is consistent.