A profile picture makes an app feel personal. Learn how to implement a robust avatar system with user uploads, Gravatar fallbacks, and SVG initials avatars as the final fallback.
A robust avatar system has three layers, tried in order:
This pattern means zero "broken image" states — the generated avatar is always the fallback of last resort.
// Store in Supabase Storage or S3, return a signed URL
async function uploadAvatar(userId: string, file: File): Promise<string> {
const ext = file.name.split(".").pop()
const path = `avatars/${userId}.${ext}`
const { error } = await supabase.storage
.from("public")
.upload(path, file, { upsert: true, contentType: file.type })
if (error) throw error
const { data } = supabase.storage.from("public").getPublicUrl(path)
return data.publicUrl
}
Gravatar links an email address to a profile photo. If the user has a Gravatar, it loads automatically.
import { createHash } from "crypto" // Node.js; use SubtleCrypto in browser
function gravatarUrl(email: string, size = 80): string {
const hash = createHash("md5").update(email.toLowerCase().trim()).digest("hex")
return `https://www.gravatar.com/avatar/${hash}?s=${size}&d=404`
}
// d=404 returns a 404 if the user has no Gravatar (we catch this in the img's onError)
// Browser version using SubtleCrypto
async function gravatarUrlBrowser(email: string, size = 80): Promise<string> {
const encoder = new TextEncoder()
const data = encoder.encode(email.toLowerCase().trim())
const hashBuffer = await crypto.subtle.digest("MD5", data) // MD5 not available in SubtleCrypto!
// Use a tiny md5 library for browser:
// import md5 from "blueimp-md5"
// return `https://www.gravatar.com/avatar/${md5(email.toLowerCase().trim())}?s=${size}&d=404`
}
Note: SubtleCrypto does not support MD5 (only SHA-256+). For browser Gravatar hashing, use a small MD5 library like blueimp-md5 (1.4KB).
function initialsAvatar(name: string, size = 80): string {
const words = name.trim().split(/\s+/)
const initials = words.length >= 2
? (words[0][0] + words[words.length - 1][0]).toUpperCase()
: name.slice(0, 2).toUpperCase()
// Deterministic color from name (consistent across renders)
const hue = Array.from(name).reduce((acc, c) => acc + c.charCodeAt(0), 0) % 360
const bg = `hsl(${hue}, 55%, 45%)`
const svg = [
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${size} ${size}" width="${size}" height="${size}">`,
`<circle cx="${size/2}" cy="${size/2}" r="${size/2}" fill="${bg}"/>`,
`<text x="50%" y="50%" dominant-baseline="middle" text-anchor="middle"`,
` font-family="system-ui,-apple-system,sans-serif" font-size="${size * 0.38}"`,
` font-weight="600" fill="white">${initials}</text>`,
`</svg>`
].join("")
return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`
}
import { useState } from "react"
import md5 from "blueimp-md5"
interface AvatarProps {
name: string
email?: string
photoUrl?: string
size?: number
className?: string
}
export function Avatar({ name, email, photoUrl, size = 40, className = "" }: AvatarProps) {
const [src, setSrc] = useState<string>(
photoUrl // Layer 1: uploaded photo
?? (email
? `https://www.gravatar.com/avatar/${md5(email.toLowerCase().trim())}?s=${size}&d=404`
: initialsAvatar(name, size)) // Layer 2: Gravatar, Layer 3: initials
)
function handleError() {
// Gravatar 404 → fall to initials
setSrc(initialsAvatar(name, size))
}
return (
<img
src={src}
onError={handleError}
alt={name}
width={size}
height={size}
className={`rounded-full object-cover ${className}`}
style={{ width: size, height: size }}
/>
)
}
// Cache generated SVGs to avoid re-computation on every render
const avatarCache = new Map<string, string>()
function cachedInitialsAvatar(name: string, size: number): string {
const key = `${name}:${size}`
if (!avatarCache.has(key)) {
avatarCache.set(key, initialsAvatar(name, size))
}
return avatarCache.get(key)!
}
Preview and download custom initials avatars with HeoLab's Avatar Generator.
The three-layer pattern — user photo → Gravatar → generated initials — ensures every user always has a meaningful avatar while respecting those who have already set up a Gravatar. The SVG initials approach is deterministic (same name always produces the same color), works offline, adds zero bytes to your bundle, and renders crisply at any size.