UI Patterns

Avatar

Avatar patterns — initials placeholder with a deterministic solid or gradient color from a hash of the name.

Examples

Initials with hashed color

When you don't have a profile picture (new account, no upload, image 404), fall back to the user's initials on a colored disc. The color is picked deterministically from a hash of the name, so the same person always gets the same swatch — that gives the avatar real recognition value across pages and lists, instead of the visual noise a single grey placeholder produces.

AL
components/examples/avatar-placeholder.tsx
"use client";

import * as React from "react";

import { cn } from "@/lib/utils";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";

// Curated palette: each entry pairs well with white text. Random HSL hue
// gives muddy yellows and greens; a hand-picked list keeps every avatar
// readable.
const PALETTE = [
  "bg-rose-500",
  "bg-pink-500",
  "bg-fuchsia-500",
  "bg-purple-500",
  "bg-violet-500",
  "bg-indigo-500",
  "bg-blue-500",
  "bg-sky-600",
  "bg-cyan-600",
  "bg-teal-600",
  "bg-emerald-600",
  "bg-green-600",
  "bg-lime-600",
  "bg-amber-600",
  "bg-orange-500",
  "bg-red-500",
];

// djb2 — small, well-distributed string hash. Locale-insensitive on the
// UTF-16 code units, which is what we want for a deterministic mapping.
function hash(str: string): number {
  let h = 5381;
  for (let i = 0; i < str.length; i++) {
    h = ((h << 5) + h + str.charCodeAt(i)) | 0;
  }
  return h;
}

function colorFor(name: string): string {
  const idx = Math.abs(hash(name.toLowerCase())) % PALETTE.length;
  return PALETTE[idx];
}

// Take first code point so surrogate-pair scripts (emoji, some CJK) don't
// get sliced in half.
function firstGlyph(token: string): string {
  const cp = token.codePointAt(0);
  return cp ? String.fromCodePoint(cp) : "";
}

function getInitials(name: string): string {
  const tokens = name.trim().split(/\s+/).filter(Boolean);
  if (tokens.length === 0) return "?";
  if (tokens.length === 1) return firstGlyph(tokens[0]).toUpperCase();
  return (
    firstGlyph(tokens[0]) + firstGlyph(tokens[tokens.length - 1])
  ).toUpperCase();
}

const SAMPLES = [
  "Ada Lovelace",
  "Grace Hopper",
  "Linus Torvalds",
  "Margaret Hamilton",
  "Brendan Eich",
  "Bjarne Stroustrup",
];

export function AvatarPlaceholderDemo() {
  const id = React.useId();
  const [name, setName] = React.useState("Ada Lovelace");

  return (
    <div className="grid w-full gap-4">
      <div className="flex justify-center">
        <Avatar className="size-20" aria-label={name || "Empty"}>
          <AvatarFallback className={cn("text-2xl text-white", colorFor(name))}>
            {getInitials(name)}
          </AvatarFallback>
        </Avatar>
      </div>
      <div className="grid gap-2">
        <Label htmlFor={id}>Name</Label>
        <Input
          id={id}
          value={name}
          onChange={(e) => setName(e.target.value)}
          placeholder="Type a name..."
        />
      </div>
      <div className="flex flex-wrap items-center justify-center gap-2">
        {SAMPLES.map((n) => (
          <button
            key={n}
            type="button"
            onClick={() => setName(n)}
            title={n}
            className="focus-visible:ring-ring/50 rounded-full outline-none focus-visible:ring-[3px]"
          >
            <Avatar aria-label={n}>
              <AvatarFallback className={cn("text-white", colorFor(n))}>
                {getInitials(n)}
              </AvatarFallback>
            </Avatar>
          </button>
        ))}
      </div>
    </div>
  );
}

Notes:

  • Initials. Take the first code point of the first and last whitespace- separated token. codePointAt(0) (not charAt(0)) avoids slicing surrogate pairs in half — important for emoji and supplementary CJK. Single-token names get one letter; empty input gets ?.
  • Hash. djb2 is small, branch-free, and well-distributed for short ASCII strings. Don't use String.prototype numeric coercion or Date.now() — the whole point is determinism: refresh the page, see the same color. Lowercase before hashing so Jane Doe and jane doe collide.
  • Curated palette beats random HSL. A naive hsl(${hash % 360}, 70%, 50%) produces muddy yellows around hue 60° and unreadable cyans around 180°. Picking from a hand-picked list of ~16 Tailwind hues guarantees every avatar pairs cleanly with white text and stays in your design system.
  • Keep the palette length coprime with common name patterns — 16 works well; avoid 26 (alphabet length) which clusters by surname initial.
  • Accessibility. The colored disc is decorative; the initials inside it carry no semantic meaning to a screen reader. Set aria-label on the Avatar root with the full name, so AT announces "Ada Lovelace" rather than "A L". The visual initials stay as text content.
  • Color is not data. The hue is a recognition aid, not a category. Don't encode role, status, or membership in the avatar color — users will pattern-match and you'll regret it the first time someone changes their display name.
  • For server-rendered or email contexts where Tailwind classes aren't available, render the same hash to an inline style={{ background: ... }} with the hex equivalents of the palette entries.
  • For something richer than initials — geometric patterns, cartoon faces, pixel identicons — see Alternatives.

Initials with gradient

Same pattern, richer swatch: instead of one palette entry, pick two and blend them into a linear gradient, with the rotation angle also derived from the hash. Same determinism (one name, always the same gradient), dramatically more visual variety — 16 colors give 16 solid swatches but 16 × 15 × 360 ≈ 86,400 distinct gradients. This is the technique behind Vercel's avatar endpoint and Mark Miro's hash-gradient explorer.

AL
components/examples/avatar-gradient.tsx
"use client";

import * as React from "react";

import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";

// Same curated palette as the solid-color demo, as hex: two picks combine
// into a linear gradient, so the colors need to stay vibrant next to each
// other. Random HSL pairs clash; this list has been checked pairwise.
const COLORS = [
  "#f43f5e", // rose-500
  "#ec4899", // pink-500
  "#d946ef", // fuchsia-500
  "#a855f7", // purple-500
  "#8b5cf6", // violet-500
  "#6366f1", // indigo-500
  "#3b82f6", // blue-500
  "#0284c7", // sky-600
  "#0891b2", // cyan-600
  "#0d9488", // teal-600
  "#059669", // emerald-600
  "#16a34a", // green-600
  "#65a30d", // lime-600
  "#d97706", // amber-600
  "#f97316", // orange-500
  "#ef4444", // red-500
];

function hash(str: string): number {
  let h = 5381;
  for (let i = 0; i < str.length; i++) {
    h = ((h << 5) + h + str.charCodeAt(i)) | 0;
  }
  return h;
}

// Slice three independent values out of one 32-bit hash: two palette indices
// (forced to differ) and a gradient rotation in degrees.
function gradientFor(name: string): string {
  const h = Math.abs(hash(name.toLowerCase()));
  const i1 = h % COLORS.length;
  const i2 = (i1 + 1 + ((h >> 8) % (COLORS.length - 1))) % COLORS.length;
  const angle = (h >> 16) % 360;
  return `linear-gradient(${angle}deg, ${COLORS[i1]}, ${COLORS[i2]})`;
}

function firstGlyph(token: string): string {
  const cp = token.codePointAt(0);
  return cp ? String.fromCodePoint(cp) : "";
}

function getInitials(name: string): string {
  const tokens = name.trim().split(/\s+/).filter(Boolean);
  if (tokens.length === 0) return "?";
  if (tokens.length === 1) return firstGlyph(tokens[0]).toUpperCase();
  return (
    firstGlyph(tokens[0]) + firstGlyph(tokens[tokens.length - 1])
  ).toUpperCase();
}

const SAMPLES = [
  "Ada Lovelace",
  "Grace Hopper",
  "Linus Torvalds",
  "Margaret Hamilton",
  "Brendan Eich",
  "Bjarne Stroustrup",
];

export function AvatarGradientDemo() {
  const id = React.useId();
  const [name, setName] = React.useState("Ada Lovelace");

  return (
    <div className="grid w-full gap-4">
      <div className="flex justify-center">
        <Avatar className="size-20" aria-label={name || "Empty"}>
          <AvatarFallback
            className="text-2xl text-white drop-shadow-sm"
            style={{ backgroundImage: gradientFor(name) }}
          >
            {getInitials(name)}
          </AvatarFallback>
        </Avatar>
      </div>
      <div className="grid gap-2">
        <Label htmlFor={id}>Name</Label>
        <Input
          id={id}
          value={name}
          onChange={(e) => setName(e.target.value)}
          placeholder="Type a name..."
        />
      </div>
      <div className="flex flex-wrap items-center justify-center gap-2">
        {SAMPLES.map((n) => (
          <button
            key={n}
            type="button"
            onClick={() => setName(n)}
            title={n}
            className="focus-visible:ring-ring/50 rounded-full outline-none focus-visible:ring-[3px]"
          >
            <Avatar aria-label={n}>
              <AvatarFallback
                className="text-white drop-shadow-sm"
                style={{ backgroundImage: gradientFor(n) }}
              >
                {getInitials(n)}
              </AvatarFallback>
            </Avatar>
          </button>
        ))}
      </div>
    </div>
  );
}

Notes:

  • Three values from one hash. h % N for the first color index, (h >> 8) % (N-1) for the offset to the second (so they never collide), (h >> 16) % 360 for the angle. A 32-bit hash has more than enough entropy; no need to call hash() three times.
  • Hex, not Tailwind classes. The gradient is built at runtime, so the colors have to exist as strings the CSS engine can read — background-image: linear-gradient(...) via inline style. Tailwind's JIT can't see into dynamically-built class names; trying to cn together from-[${color}] will silently fail in production.
  • Pair the palette, don't randomize it. Two randomly-chosen HSL colors will eventually produce a puce-and-olive avatar. A hand-picked list where every pair is known-readable keeps the output consistently attractive. If you want more variety, grow the palette — don't switch to random HSL.
  • drop-shadow-sm on the initials gives them a faint outline that preserves contrast when the gradient passes through a lighter stop. Without it, white letters can disappear into yellows and limes.
  • Same accessibility rules apply: aria-label on the Avatar root carries the full name; the gradient is decorative.
  • For SVG output (email, social share cards, OG images) build the same gradient as an <linearGradient> in a <defs> block and fill a <circle> or <rect> with it. That's what Vercel's avatar endpoint does — the algorithm is identical, only the rendering target changes.

Alternatives

Initials + color is the cheapest visual identity you can ship. When you want something richer — unique per user, harder to collide, or more stylistically distinctive — reach for one of these:

  • Boring Avatars — six SVG variants (beam, marble, pixel, sunset, ring, bauhaus) derived from a name plus a palette. Drops straight into AvatarFallback as an SVG, keeps the same "one name, one look" property as this page's demos.
  • awesome-identicons — curated list of identicon libraries, algorithms, and research across many languages and visual styles. Start here before writing your own.
  • hash-avataaars — feeds a hash into the Avataaars cartoon-face construction kit, so each input yields a deterministic illustrated character with varied hair, clothing, and accessories.
  • blockies — the 8×8 pixel identicon style used as the default avatar in Ethereum wallets. Compact, high-contrast visual fingerprint of an address or any arbitrary string.
  • Opepen standard — Zapper's per-address avatar spec combining an Opepen-style illustration with a deterministic palette. Designed for web3 identity but the algorithm is general.
  • ACM 10.5555/3489212.3489338 — academic reference on visual identity hashing, for background on why these systems work (and where they fail: near-collisions for similar inputs, or large palettes where users can't distinguish two swatches).

On this page