Tailwind Avatar Component: Image, Initials, Group, Status
Build a Tailwind avatar component from scratch — image fallback, initials, group stacking, and online status indicators. Real code, no fluff.
Why Avatar Components Are Harder Than They Look
Honestly, every design system underestimates the avatar. It looks simple — a circle with a photo. But the moment you wire it up to real user data, you're dealing with broken image URLs, missing names, multiple sizes, overlapping groups, and status badges that have to sit in exactly the right spot regardless of the avatar's size.
This article walks through building a proper Tailwind avatar component in React. We're talking image display with graceful fallback to initials, a stacked avatar group, and an online/offline status indicator — all with Tailwind v4.0.2 utility classes and no extra CSS files.
We're also going to avoid the usual trap of hardcoding pixel sizes everywhere. Instead we'll use a variant pattern driven by data attributes so the component stays composable. If you've ever fought with Tailwind component patterns to keep things DRY, this approach will feel familiar.
Base Avatar: Image with Initials Fallback
The core avatar needs to render an <img> when a src is available and fall back to a colored div showing the user's initials when it's not — or when the image 404s. That second case is the one people forget until production.
Here's the base component. It handles both cases and exposes a size prop with four variants: sm (24px), md (40px, default), lg (56px), and xl (80px).
import { useState } from 'react'
type AvatarSize = 'sm' | 'md' | 'lg' | 'xl'
const sizeClasses: Record<AvatarSize, string> = {
sm: 'h-6 w-6 text-xs',
md: 'h-10 w-10 text-sm',
lg: 'h-14 w-14 text-base',
xl: 'h-20 w-20 text-xl',
}
function getInitials(name: string): string {
return name
.split(' ')
.slice(0, 2)
.map((word) => word[0]?.toUpperCase() ?? '')
.join('')
}
interface AvatarProps {
src?: string
name: string
size?: AvatarSize
className?: string
}
export function Avatar({ src, name, size = 'md', className = '' }: AvatarProps) {
const [imgError, setImgError] = useState(false)
const base = `inline-flex items-center justify-center rounded-full overflow-hidden font-medium select-none ${sizeClasses[size]} ${className}`
if (src && !imgError) {
return (
<div className={base}>
<img
src={src}
alt={name}
className="h-full w-full object-cover"
onError={() => setImgError(true)}
/>
</div>
)
}
// deterministic color from name hash
const colors = [
'bg-violet-500 text-white',
'bg-sky-500 text-white',
'bg-emerald-500 text-white',
'bg-rose-500 text-white',
'bg-amber-500 text-white',
]
const index = name.charCodeAt(0) % colors.length
return (
<div className={`${base} ${colors[index]}`}>
{getInitials(name)}
</div>
)
}The onError handler flips imgError to true, which causes a re-render into the initials path. The color is deterministic based on the first character's char code — so the same user always gets the same color across page loads without storing anything.
Adding a Status Indicator Badge
Status dots — online, away, busy, offline — need to be absolutely positioned relative to the avatar container. The tricky part is keeping them anchored to the bottom-right corner regardless of which size variant is active.
We wrap the base Avatar in a relative container and add a small <span> for the badge. The dot size should scale slightly with the avatar, but not proportionally — a 4px dot on a 24px avatar looks fine; you don't need a 13px dot on an 80px one.
type Status = 'online' | 'away' | 'busy' | 'offline'
const statusColors: Record<Status, string> = {
online: 'bg-emerald-400',
away: 'bg-amber-400',
busy: 'bg-rose-500',
offline: 'bg-zinc-400',
}
const statusSizes: Record<AvatarSize, string> = {
sm: 'h-1.5 w-1.5',
md: 'h-2.5 w-2.5',
lg: 'h-3 w-3',
xl: 'h-3.5 w-3.5',
}
interface AvatarWithStatusProps extends AvatarProps {
status?: Status
}
export function AvatarWithStatus({ status, size = 'md', ...rest }: AvatarWithStatusProps) {
return (
<div className="relative inline-flex">
<Avatar size={size} {...rest} />
{status && (
<span
className={`
absolute bottom-0 right-0 block rounded-full
ring-2 ring-white dark:ring-zinc-900
${statusColors[status]}
${statusSizes[size]}
`}
/>
)}
</div>
)
}The ring-2 ring-white trick is what makes status dots look crisp — it creates a thin white border between the dot and the avatar edge without needing an actual border. On dark backgrounds you swap it to ring-zinc-900. If you're building a dark-mode-first app, check out how to wire up a theme toggle in React so the ring color actually switches.
Stacked Avatar Group Component
Avatar groups are used in task lists, comment threads, and anywhere you want to show "these 5 people are involved" without burning horizontal space. The classic look is overlapping circles with a small negative margin between them.
The overflow count (the +N bubble) matters too. If you have 20 avatars and only show 4, users need to know there are more. Keep the overflow bubble visually consistent with the avatar style — same size, same border ring.
interface AvatarGroupProps {
users: { src?: string; name: string }[]
max?: number
size?: AvatarSize
}
const groupOffset: Record<AvatarSize, string> = {
sm: '-space-x-1.5',
md: '-space-x-2',
lg: '-space-x-3',
xl: '-space-x-4',
}
export function AvatarGroup({ users, max = 4, size = 'md' }: AvatarGroupProps) {
const visible = users.slice(0, max)
const overflow = users.length - max
return (
<div className={`flex items-center ${groupOffset[size]}`}>
{visible.map((user, i) => (
<div
key={i}
className="ring-2 ring-white dark:ring-zinc-900 rounded-full"
title={user.name}
>
<Avatar src={user.src} name={user.name} size={size} />
</div>
))}
{overflow > 0 && (
<div
className={`
inline-flex items-center justify-center rounded-full
bg-zinc-100 dark:bg-zinc-800
text-zinc-600 dark:text-zinc-300 font-medium
ring-2 ring-white dark:ring-zinc-900
${sizeClasses[size]}
`}
>
+{overflow}
</div>
)}
</div>
)
}The ring-2 ring-white on each item in the group creates the separation gap between overlapping circles. It's purely visual — no negative margin adjustments needed per item. The Tailwind space-x negative variant handles the overlap amount.
Handling Responsive Sizes with Container Queries
Here's the thing: a fixed md avatar in a sidebar feels fine on desktop but can look cramped when the sidebar collapses to 240px. Viewport-based responsive breakpoints don't help when the avatar lives inside a variable-width container.
Tailwind v4.0.2 ships container queries via @container. You can wrap your avatar group in a container and let it automatically drop to sm size when the parent is narrow. This pairs well with the Tailwind container queries patterns if you want to go deeper on this approach.
For most projects, just accepting an explicit size prop and wiring it to your layout logic is simpler. But if you're building a truly reusable component for a design system used across many page layouts, container queries are the right call — you get size adaptation without prop drilling through every intermediate component.
Accessible Avatars: What Most Tutorials Skip
Can't skip this part. Avatars are often interactive — clicking one opens a profile, hovering shows a tooltip. Screen readers need context. A bare <img> with no alt or an alt of "avatar" is nearly useless.
For image avatars, the alt should be the user's full name — alt={name} as we have above. For initials-only avatars, the containing div should get aria-label={name} and role="img". Tooltips via title attributes are a decent progressive enhancement but they don't work on touch devices, so consider a proper tooltip component for interactive avatars.
If the avatar is purely decorative (like in a comment feed where the name is already printed next to it), set alt="" on the image and aria-hidden="true" on the wrapper. Don't annotate the same information twice — it just makes the screen reader read the name out twice, which is annoying. Accessibility is about being useful, not about checking boxes.
Styling Variants: Borders, Shadows, and Custom Colors
Sometimes you need a bordered avatar — like in a settings page where the current user's avatar should stand out. A simple ring-2 ring-violet-500 does the job. For glassmorphism-style UIs, you might want ring-1 ring-white/20 with a backdrop blur behind the group — check out tailwind glassmorphism advanced for how to layer those effects.
Drop shadows on circular elements can look off if you use shadow-md directly — it creates a rectangular shadow behind the circle. Use shadow-[0_4px_12px_rgba(0,0,0,0.15)] with the rounded-full to get a proper circular shadow. That rgba(255,255,255,0.15) inner glow trick also works well for dark-themed avatar cards.
For custom brand colors, resist hardcoding hex values in the component. Use CSS variables instead — --avatar-bg and --avatar-fg — and set them from a Tailwind style prop. That way the component works in any color scheme without modification. If you're using Tailwind OKLCH colors, you can define perceptually uniform avatar palette colors that look consistent across all hue values.
Putting It Together: A Real-World Usage Example
Here's what a realistic usage pattern looks like in a task card — showing assigned users with status, plus an overflow count. This is the kind of thing you'd find in a project management SaaS dashboard.
const assignees = [
{ name: 'Priya Sharma', src: 'https://example.com/priya.jpg', status: 'online' as const },
{ name: 'Luca Bianchi', src: undefined, status: 'away' as const },
{ name: 'Jordan Lee', src: 'https://example.com/jordan.jpg', status: 'offline' as const },
{ name: 'Mara Voss', src: undefined, status: 'busy' as const },
{ name: 'Tariq Nasser', src: 'https://example.com/tariq.jpg', status: 'online' as const },
]
export function TaskCard() {
return (
<div className="rounded-xl border border-zinc-200 dark:border-zinc-700 bg-white dark:bg-zinc-900 p-4">
<p className="text-sm font-medium text-zinc-900 dark:text-zinc-100 mb-3">
Redesign onboarding flow
</p>
<div className="flex items-center gap-2">
<div className="flex -space-x-2">
{assignees.slice(0, 3).map((user, i) => (
<div key={i} className="ring-2 ring-white dark:ring-zinc-900 rounded-full">
<AvatarWithStatus
src={user.src}
name={user.name}
status={user.status}
size="sm"
/>
</div>
))}
</div>
{assignees.length > 3 && (
<span className="text-xs text-zinc-500">+{assignees.length - 3} more</span>
)}
</div>
</div>
)
}Notice Luca and Mara have no src — the component automatically renders their initials with a deterministic color. No conditional rendering, no extra loading state. It just works. That's the whole point of building the fallback directly into the base component rather than leaving it to the consumer to handle.
FAQ
The deterministic approach shown here uses name.charCodeAt(0) % colors.length which gives the same color every time for the same name. If you need more uniqueness (to avoid two users getting the same color), hash the full name string — sum all char codes and mod by your color count. Still deterministic, still no storage needed.
For most avatars, a loading skeleton is overkill. The image either loads quickly or errors. Set onError to flip to the initials fallback and that covers 99% of cases. If you genuinely need a loading state (e.g., large images or slow connections), add a loaded state initialized to false, flip it onLoad, and show a bg-zinc-200 animate-pulse placeholder until then.
Tailwind's built-in -space-x-2 gives you 8px of overlap (since the space utility is negated). For more overlap, use -space-x-3 (12px) or -space-x-4 (16px). If you need a non-standard value like 10px, use the arbitrary value syntax: -space-x-[10px]. Available in Tailwind v4.0.2 without any config changes.
The ring-2 ring-white separator ring creates a white gap between the dot and the avatar. On dark UIs, that white ring is jarring — switch it to ring-zinc-900 or whatever your background color is. In practice, use ring-white dark:ring-zinc-900 and it adapts automatically to your Tailwind dark mode class.
Yes. Add animate-ping to a duplicate absolute element behind the dot for a ripple effect: render two <span> elements in the same position, one with animate-ping opacity-75 and one static. Tailwind ships animate-ping out of the box. Keep the ping subtle — full opacity pings on every avatar in a group get distracting fast.
Add a title attribute to each avatar wrapper for mouse users, and wrap the whole group in a <div role="group" aria-label="Assigned to: Priya, Luca, Jordan and 2 more">. Generate that label string from the user array before rendering. Screen readers will announce the group summary rather than reading each avatar individually.