Web InnoventixFreeCode

Initials Avatar

Original · free

initials avatars with colours

byWeb InnoventixReact + Tailwind
avinitialsavatars
Open

Copy prompt gives Claude Code, Cursor or v0 a ready-to-paste prompt that recreates this exact component.

npx shadcn@latest add https://webinnoventix.com/r/av-initials.json
av-initials.tsx
"use client";

import { useId, useRef, useState } from "react";
import type {
  ChangeEvent,
  FormEvent,
  KeyboardEvent as ReactKeyboardEvent,
} from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";

type Size = "sm" | "md" | "lg" | "xl";
type Shape = "circle" | "square";
type Variant = "soft" | "solid";
type StatusKey = "online" | "away" | "busy" | "offline";

type Teammate = {
  id: string;
  name: string;
  role: string;
  status: StatusKey;
};

type Palette = {
  name: string;
  soft: string;
  solid: string;
};

const PALETTES: ReadonlyArray<Palette> = [
  {
    name: "indigo",
    soft: "bg-indigo-100 text-indigo-700 dark:bg-indigo-400/15 dark:text-indigo-300",
    solid: "bg-indigo-600 text-white",
  },
  {
    name: "violet",
    soft: "bg-violet-100 text-violet-700 dark:bg-violet-400/15 dark:text-violet-300",
    solid: "bg-violet-600 text-white",
  },
  {
    name: "emerald",
    soft: "bg-emerald-100 text-emerald-700 dark:bg-emerald-400/15 dark:text-emerald-300",
    solid: "bg-emerald-600 text-white",
  },
  {
    name: "rose",
    soft: "bg-rose-100 text-rose-700 dark:bg-rose-400/15 dark:text-rose-300",
    solid: "bg-rose-600 text-white",
  },
  {
    name: "amber",
    soft: "bg-amber-100 text-amber-800 dark:bg-amber-400/15 dark:text-amber-300",
    solid: "bg-amber-600 text-white",
  },
  {
    name: "sky",
    soft: "bg-sky-100 text-sky-700 dark:bg-sky-400/15 dark:text-sky-300",
    solid: "bg-sky-600 text-white",
  },
  {
    name: "slate",
    soft: "bg-slate-200 text-slate-700 dark:bg-slate-400/15 dark:text-slate-300",
    solid: "bg-slate-700 text-white",
  },
  {
    name: "zinc",
    soft: "bg-zinc-200 text-zinc-700 dark:bg-zinc-400/15 dark:text-zinc-300",
    solid: "bg-zinc-700 text-white",
  },
];

const SIZE: Record<Size, { box: string; text: string; dot: string }> = {
  sm: { box: "h-9 w-9", text: "text-xs", dot: "h-2.5 w-2.5" },
  md: { box: "h-12 w-12", text: "text-sm", dot: "h-3 w-3" },
  lg: { box: "h-16 w-16", text: "text-lg", dot: "h-3.5 w-3.5" },
  xl: { box: "h-24 w-24", text: "text-3xl", dot: "h-5 w-5" },
};

const STATUS: Record<StatusKey, { label: string; dot: string; live: boolean }> = {
  online: { label: "Online", dot: "bg-emerald-500", live: true },
  away: { label: "Away", dot: "bg-amber-500", live: false },
  busy: { label: "In a meeting", dot: "bg-rose-500", live: false },
  offline: { label: "Offline", dot: "bg-slate-400 dark:bg-slate-500", live: false },
};

const RING = "ring-2 ring-white dark:ring-slate-900";

const FOCUS =
  "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 dark:focus-visible:ring-indigo-400 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-slate-900";

function hashString(str: string): number {
  let h = 0;
  for (let i = 0; i < str.length; i += 1) {
    h = (h << 5) - h + str.charCodeAt(i);
    h |= 0;
  }
  return Math.abs(h);
}

function paletteFor(name: string): Palette {
  const key = name.trim().toLowerCase();
  return PALETTES[hashString(key) % PALETTES.length];
}

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

const STYLE_OPTIONS: ReadonlyArray<{ value: Variant; label: string }> = [
  { value: "soft", label: "Soft" },
  { value: "solid", label: "Solid" },
];

const SHAPE_OPTIONS: ReadonlyArray<{ value: Shape; label: string }> = [
  { value: "circle", label: "Circle" },
  { value: "square", label: "Rounded" },
];

const SIZE_OPTIONS: ReadonlyArray<{ value: Size; label: string }> = [
  { value: "sm", label: "S" },
  { value: "md", label: "M" },
  { value: "lg", label: "L" },
  { value: "xl", label: "XL" },
];

const SEED_TEAM: ReadonlyArray<Teammate> = [
  { id: "seed-1", name: "Amara Okafor", role: "Product Designer", status: "online" },
  { id: "seed-2", name: "Liang Wei", role: "Staff Engineer", status: "busy" },
  { id: "seed-3", name: "Sofia Marchetti", role: "Data Scientist", status: "away" },
  { id: "seed-4", name: "Ravi Deshmukh", role: "Engineering Manager", status: "online" },
  { id: "seed-5", name: "Noor Al-Rashid", role: "UX Researcher", status: "offline" },
  { id: "seed-6", name: "Diego Fuentes", role: "Frontend Engineer", status: "online" },
];

function Avatar({
  name,
  size,
  shape,
  variant,
  status,
  showStatus,
  decorative = false,
  sheen = false,
  reduce = false,
}: {
  name: string;
  size: Size;
  shape: Shape;
  variant: Variant;
  status: StatusKey;
  showStatus: boolean;
  decorative?: boolean;
  sheen?: boolean;
  reduce?: boolean;
}) {
  const palette = paletteFor(name);
  const colorCls = variant === "soft" ? palette.soft : palette.solid;
  const shapeCls = shape === "circle" ? "rounded-full" : "rounded-2xl";
  const dims = SIZE[size];
  const st = STATUS[status];

  return (
    <span className={`relative inline-flex shrink-0 ${dims.box}`}>
      <span
        className={`relative flex h-full w-full items-center justify-center overflow-hidden font-semibold tracking-tight select-none ${shapeCls} ${colorCls} ${dims.text}`}
        role={decorative ? undefined : "img"}
        aria-hidden={decorative ? true : undefined}
        aria-label={decorative ? undefined : name.trim() || "Unnamed teammate"}
        title={decorative ? undefined : name.trim() || undefined}
      >
        {getInitials(name)}
        {sheen && !reduce ? (
          <span className="avin-shine pointer-events-none absolute inset-0 bg-gradient-to-r from-transparent via-white/40 to-transparent" />
        ) : null}
      </span>

      {showStatus ? (
        <span className="absolute bottom-0 right-0 flex">
          {st.live && !reduce ? (
            <span
              className={`avin-ping absolute inset-0 rounded-full bg-emerald-400 ${dims.dot}`}
              aria-hidden
            />
          ) : null}
          <span
            className={`relative block rounded-full ${dims.dot} ${st.dot} ${RING}`}
            aria-hidden
          />
        </span>
      ) : null}
    </span>
  );
}

function Segmented<T extends string>({
  label,
  value,
  options,
  onChange,
}: {
  label: string;
  value: T;
  options: ReadonlyArray<{ value: T; label: string }>;
  onChange: (value: T) => void;
}) {
  const refs = useRef<Array<HTMLButtonElement | null>>([]);
  const current = options.findIndex((o) => o.value === value);

  const selectAt = (index: number) => {
    const next = (index + options.length) % options.length;
    onChange(options[next].value);
    refs.current[next]?.focus();
  };

  const onKeyDown = (e: ReactKeyboardEvent<HTMLButtonElement>) => {
    switch (e.key) {
      case "ArrowRight":
      case "ArrowDown":
        e.preventDefault();
        selectAt(current + 1);
        break;
      case "ArrowLeft":
      case "ArrowUp":
        e.preventDefault();
        selectAt(current - 1);
        break;
      case "Home":
        e.preventDefault();
        selectAt(0);
        break;
      case "End":
        e.preventDefault();
        selectAt(options.length - 1);
        break;
      default:
        break;
    }
  };

  return (
    <div>
      <span className="mb-2 block text-xs font-medium uppercase tracking-wider text-slate-500 dark:text-slate-400">
        {label}
      </span>
      <div
        role="radiogroup"
        aria-label={label}
        className="inline-flex gap-1 rounded-xl bg-slate-100 p-1 dark:bg-slate-800"
      >
        {options.map((o, i) => {
          const active = o.value === value;
          return (
            <button
              key={o.value}
              type="button"
              role="radio"
              aria-checked={active}
              tabIndex={active ? 0 : -1}
              ref={(el) => {
                refs.current[i] = el;
              }}
              onClick={() => onChange(o.value)}
              onKeyDown={onKeyDown}
              className={`min-w-[3rem] rounded-lg px-3 py-1.5 text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 dark:focus-visible:ring-indigo-400 ${
                active
                  ? "bg-white text-slate-900 shadow-sm dark:bg-slate-950 dark:text-white"
                  : "text-slate-500 hover:text-slate-800 dark:text-slate-400 dark:hover:text-slate-200"
              }`}
            >
              {o.label}
            </button>
          );
        })}
      </div>
    </div>
  );
}

export default function AvInitials() {
  const reduce = !!useReducedMotion();
  const headingId = useId();
  const nameId = useId();
  const statusId = useId();

  const [variant, setVariant] = useState<Variant>("soft");
  const [shape, setShape] = useState<Shape>("circle");
  const [size, setSize] = useState<Size>("lg");
  const [showStatus, setShowStatus] = useState(true);

  const [team, setTeam] = useState<Teammate[]>(() => SEED_TEAM.slice());
  const [draftName, setDraftName] = useState("Priya Nair");
  const [draftStatus, setDraftStatus] = useState<StatusKey>("online");
  const idRef = useRef(0);

  const trimmed = draftName.trim();
  const previewPalette = paletteFor(trimmed || "?");

  const handleAdd = (e: FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    const name = draftName.trim();
    if (!name) return;
    idRef.current += 1;
    setTeam((prev) => [
      { id: `added-${idRef.current}`, name, role: "New teammate", status: draftStatus },
      ...prev,
    ]);
    setDraftName("");
  };

  const removeMember = (id: string) => {
    setTeam((prev) => prev.filter((m) => m.id !== id));
  };

  const stackVisible = team.slice(0, 6);
  const overflow = team.length - stackVisible.length;

  const itemInitial = reduce ? { opacity: 0 } : { opacity: 0, y: 10, scale: 0.96 };
  const itemAnimate = { opacity: 1, y: 0, scale: 1 };
  const itemExit = reduce
    ? { opacity: 0, transition: { duration: 0.12 } }
    : { opacity: 0, scale: 0.9, transition: { duration: 0.16 } };
  const itemTransition = reduce
    ? { duration: 0 }
    : { type: "spring" as const, stiffness: 420, damping: 32 };

  return (
    <section
      aria-labelledby={headingId}
      className="relative w-full overflow-hidden bg-slate-50 px-5 py-20 sm:px-8 dark:bg-slate-950"
    >
      <style>{`
        @keyframes avin-ping {
          0% { transform: scale(1); opacity: 0.55; }
          70%, 100% { transform: scale(2); opacity: 0; }
        }
        @keyframes avin-shine {
          0% { transform: translateX(-130%) skewX(-12deg); opacity: 0; }
          28% { opacity: 0.6; }
          60%, 100% { transform: translateX(130%) skewX(-12deg); opacity: 0; }
        }
        .avin-ping { animation: avin-ping 2.2s cubic-bezier(0, 0, 0.2, 1) infinite; }
        .avin-shine { animation: avin-shine 3.4s ease-in-out 0.5s infinite; }
        @media (prefers-reduced-motion: reduce) {
          .avin-ping, .avin-shine { animation: none !important; }
        }
      `}</style>

      <div
        aria-hidden
        className="pointer-events-none absolute -right-24 -top-24 h-72 w-72 rounded-full bg-indigo-300/25 blur-3xl dark:bg-indigo-500/10"
      />
      <div
        aria-hidden
        className="pointer-events-none absolute -bottom-24 -left-24 h-72 w-72 rounded-full bg-violet-300/25 blur-3xl dark:bg-violet-500/10"
      />

      <div className="relative mx-auto max-w-5xl">
        <header className="max-w-2xl">
          <span className="inline-flex items-center gap-2 rounded-full border border-slate-200 bg-white px-3 py-1 text-xs font-medium uppercase tracking-wider text-slate-500 dark:border-slate-800 dark:bg-slate-900 dark:text-slate-400">
            <span className="h-1.5 w-1.5 rounded-full bg-indigo-500" />
            Avatars
          </span>
          <h2
            id={headingId}
            className="mt-4 text-3xl font-semibold tracking-tight text-slate-900 sm:text-4xl dark:text-white"
          >
            Initials, coloured by name
          </h2>
          <p className="mt-3 text-base leading-relaxed text-slate-600 dark:text-slate-300">
            Every teammate gets a consistent colour derived from their name — the same
            person always lands on the same hue, no photos required. Tune the style, shape
            and size, then add someone to watch it generated instantly.
          </p>
        </header>

        <div className="mt-10 grid gap-5 lg:grid-cols-5">
          <div className="rounded-2xl border border-slate-200 bg-white p-6 lg:col-span-3 dark:border-slate-800 dark:bg-slate-900">
            <h3 className="text-sm font-semibold text-slate-900 dark:text-white">
              Display settings
            </h3>
            <p className="mt-1 text-sm text-slate-500 dark:text-slate-400">
              Applies to every avatar on this page.
            </p>

            <div className="mt-5 flex flex-wrap gap-x-8 gap-y-5">
              <Segmented label="Style" value={variant} options={STYLE_OPTIONS} onChange={setVariant} />
              <Segmented label="Shape" value={shape} options={SHAPE_OPTIONS} onChange={setShape} />
              <Segmented label="Size" value={size} options={SIZE_OPTIONS} onChange={setSize} />
            </div>

            <div className="mt-6 flex items-center justify-between gap-4 rounded-xl border border-slate-200 bg-slate-50 px-4 py-3 dark:border-slate-800 dark:bg-slate-950/60">
              <span className="flex flex-col">
                <span className="text-sm font-medium text-slate-900 dark:text-white">
                  Presence indicators
                </span>
                <span className="text-xs text-slate-500 dark:text-slate-400">
                  Show the live status dot on each avatar.
                </span>
              </span>
              <button
                type="button"
                role="switch"
                aria-checked={showStatus}
                aria-label="Toggle presence indicators"
                onClick={() => setShowStatus((v) => !v)}
                className={`relative inline-flex h-6 w-11 shrink-0 items-center rounded-full transition-colors ${FOCUS} ${
                  showStatus ? "bg-indigo-600" : "bg-slate-300 dark:bg-slate-700"
                }`}
              >
                <span
                  className={`inline-block h-5 w-5 transform rounded-full bg-white shadow-sm transition-transform ${
                    showStatus ? "translate-x-5" : "translate-x-0.5"
                  }`}
                />
              </button>
            </div>
          </div>

          <div className="rounded-2xl border border-slate-200 bg-white p-6 lg:col-span-2 dark:border-slate-800 dark:bg-slate-900">
            <h3 className="text-sm font-semibold text-slate-900 dark:text-white">
              Live preview
            </h3>

            <div className="mt-5 flex items-center gap-4">
              <Avatar
                name={trimmed || "?"}
                size="xl"
                shape={shape}
                variant={variant}
                status={draftStatus}
                showStatus={showStatus}
                decorative
                sheen
                reduce={reduce}
              />
              <div className="min-w-0">
                {trimmed ? (
                  <>
                    <p className="truncate text-sm font-medium text-slate-900 dark:text-white">
                      {trimmed}
                    </p>
                    <p className="mt-0.5 text-xs text-slate-500 dark:text-slate-400">
                      Initials{" "}
                      <span className="font-semibold text-slate-700 dark:text-slate-200">
                        {getInitials(trimmed)}
                      </span>{" "}
                      · colour{" "}
                      <span className="font-semibold capitalize text-slate-700 dark:text-slate-200">
                        {previewPalette.name}
                      </span>
                    </p>
                  </>
                ) : (
                  <p className="text-sm text-slate-500 dark:text-slate-400">
                    Type a name to preview its avatar.
                  </p>
                )}
              </div>
            </div>

            <form onSubmit={handleAdd} className="mt-6 space-y-4">
              <div>
                <label
                  htmlFor={nameId}
                  className="mb-1.5 block text-xs font-medium text-slate-600 dark:text-slate-300"
                >
                  Full name
                </label>
                <input
                  id={nameId}
                  type="text"
                  value={draftName}
                  onChange={(e: ChangeEvent<HTMLInputElement>) => setDraftName(e.target.value)}
                  placeholder="e.g. Grace Hopper"
                  autoComplete="off"
                  className={`w-full rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm text-slate-900 placeholder:text-slate-400 dark:border-slate-700 dark:bg-slate-950 dark:text-white dark:placeholder:text-slate-500 ${FOCUS}`}
                />
              </div>

              <div>
                <label
                  htmlFor={statusId}
                  className="mb-1.5 block text-xs font-medium text-slate-600 dark:text-slate-300"
                >
                  Presence
                </label>
                <select
                  id={statusId}
                  value={draftStatus}
                  onChange={(e: ChangeEvent<HTMLSelectElement>) =>
                    setDraftStatus(e.target.value as StatusKey)
                  }
                  className={`w-full rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm text-slate-900 dark:border-slate-700 dark:bg-slate-950 dark:text-white ${FOCUS}`}
                >
                  <option value="online">Online</option>
                  <option value="away">Away</option>
                  <option value="busy">In a meeting</option>
                  <option value="offline">Offline</option>
                </select>
              </div>

              <button
                type="submit"
                disabled={!trimmed}
                className={`inline-flex w-full items-center justify-center gap-2 rounded-lg bg-indigo-600 px-4 py-2.5 text-sm font-semibold text-white transition-colors hover:bg-indigo-500 disabled:cursor-not-allowed disabled:opacity-40 ${FOCUS}`}
              >
                <svg
                  viewBox="0 0 20 20"
                  fill="none"
                  aria-hidden
                  className="h-4 w-4"
                >
                  <path
                    d="M10 4.5v11M4.5 10h11"
                    stroke="currentColor"
                    strokeWidth="1.75"
                    strokeLinecap="round"
                  />
                </svg>
                Add to team
              </button>
            </form>
          </div>
        </div>

        <div className="mt-10">
          <div className="flex items-baseline justify-between">
            <h3 className="text-sm font-semibold text-slate-900 dark:text-white">
              Your team
            </h3>
            <span className="text-xs tabular-nums text-slate-500 dark:text-slate-400">
              {team.length} {team.length === 1 ? "person" : "people"}
            </span>
          </div>

          <ul className="mt-4 grid grid-cols-1 gap-3 sm:grid-cols-2">
            <AnimatePresence initial={false} mode="popLayout">
              {team.map((m) => {
                const st = STATUS[m.status];
                return (
                  <motion.li
                    key={m.id}
                    layout={!reduce}
                    initial={itemInitial}
                    animate={itemAnimate}
                    exit={itemExit}
                    transition={itemTransition}
                    className="group flex items-center gap-3 rounded-xl border border-slate-200 bg-white p-3 dark:border-slate-800 dark:bg-slate-900"
                  >
                    <Avatar
                      name={m.name}
                      size={size === "xl" ? "lg" : size}
                      shape={shape}
                      variant={variant}
                      status={m.status}
                      showStatus={showStatus}
                      decorative
                      reduce={reduce}
                    />
                    <div className="min-w-0 flex-1">
                      <p className="truncate text-sm font-medium text-slate-900 dark:text-white">
                        {m.name}
                      </p>
                      <p className="mt-0.5 flex items-center gap-1.5 text-xs text-slate-500 dark:text-slate-400">
                        <span className={`h-1.5 w-1.5 rounded-full ${st.dot}`} aria-hidden />
                        <span className="truncate">
                          {m.role} · {st.label}
                        </span>
                      </p>
                    </div>
                    <button
                      type="button"
                      onClick={() => removeMember(m.id)}
                      aria-label={`Remove ${m.name}`}
                      className={`inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-lg text-slate-400 transition-colors hover:bg-slate-100 hover:text-slate-700 dark:hover:bg-slate-800 dark:hover:text-slate-200 ${FOCUS}`}
                    >
                      <svg viewBox="0 0 20 20" fill="none" aria-hidden className="h-4 w-4">
                        <path
                          d="M6 6l8 8M14 6l-8 8"
                          stroke="currentColor"
                          strokeWidth="1.75"
                          strokeLinecap="round"
                        />
                      </svg>
                    </button>
                  </motion.li>
                );
              })}
            </AnimatePresence>
          </ul>

          {team.length === 0 ? (
            <p className="mt-4 rounded-xl border border-dashed border-slate-300 bg-white/50 px-4 py-8 text-center text-sm text-slate-500 dark:border-slate-700 dark:bg-slate-900/40 dark:text-slate-400">
              No teammates yet — add someone above to generate their avatar.
            </p>
          ) : null}
        </div>

        {team.length > 0 ? (
          <div className="mt-8 flex flex-wrap items-center gap-4 rounded-2xl border border-slate-200 bg-white p-5 dark:border-slate-800 dark:bg-slate-900">
            <div className="flex items-center">
              <div className="flex -space-x-3">
                {stackVisible.map((m) => (
                  <span key={m.id} className="relative">
                    <span className={`inline-flex rounded-full ${RING}`}>
                      <Avatar
                        name={m.name}
                        size="md"
                        shape="circle"
                        variant={variant}
                        status={m.status}
                        showStatus={false}
                        reduce={reduce}
                      />
                    </span>
                  </span>
                ))}
                {overflow > 0 ? (
                  <span
                    className={`relative inline-flex h-12 w-12 items-center justify-center rounded-full bg-slate-200 text-sm font-semibold text-slate-600 dark:bg-slate-800 dark:text-slate-300 ${RING}`}
                    aria-hidden
                  >
                    +{overflow}
                  </span>
                ) : null}
              </div>
            </div>
            <div className="min-w-0">
              <p className="text-sm font-semibold text-slate-900 dark:text-white">
                Working on Project Aurora
              </p>
              <p className="mt-0.5 text-xs text-slate-500 dark:text-slate-400">
                {team.length} contributor{team.length === 1 ? "" : "s"} in the shared workspace
              </p>
            </div>
          </div>
        ) : null}
      </div>
    </section>
  );
}

Dependencies

motion

Licence

Built by Web Innoventix. Free for personal and commercial use, no attribution required.

Built by Web Innoventix

Need a custom interface, a full website, or SEO that gets you cited by AI? We design, build and rank it end to end.

Get a free quote

Similar components

Browse all →