Web InnoventixFreeCode

Basic Avatar

Original · free

avatars in sizes with fallback

byWeb InnoventixReact + Tailwind
avbasicavatars
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-basic.json
av-basic.tsx
"use client";

import {
  useEffect,
  useRef,
  useState,
  type KeyboardEvent as ReactKeyboardEvent,
} from "react";
import { motion, useReducedMotion } from "motion/react";

type AvatarSize = "xs" | "sm" | "md" | "lg" | "xl";
type PresenceStatus = "online" | "away" | "busy" | "offline";

interface Person {
  id: string;
  name: string;
  role: string;
  src: string;
  status: PresenceStatus;
}

const PEOPLE: Person[] = [
  { id: "amara", name: "Amara Okafor", role: "Principal Designer", src: "/img/gallery/g01.webp", status: "online" },
  { id: "devon", name: "Devon Reyes", role: "Staff Engineer", src: "/img/gallery/g02.webp", status: "busy" },
  { id: "priya", name: "Priya Nair", role: "Product Manager", src: "/img/gallery/g03.webp", status: "away" },
  { id: "lucas", name: "Lucas Brandt", role: "Design Engineer", src: "/img/gallery/g04.webp", status: "online" },
  { id: "meilin", name: "Mei-Lin Chow", role: "UX Researcher", src: "/img/gallery/g05.webp", status: "offline" },
  { id: "sofia", name: "Sofia Almeida", role: "Content Strategist", src: "/img/gallery/g06.webp", status: "online" },
  { id: "idris", name: "Idris Haddad", role: "Frontend Lead", src: "/img/gallery/g07.webp", status: "busy" },
  { id: "nadia", name: "Nadia Volkov", role: "Motion Designer", src: "/img/gallery/g08.webp", status: "away" },
];

const SIZE_ORDER: AvatarSize[] = ["xs", "sm", "md", "lg", "xl"];

const SIZE_MAP: Record<
  AvatarSize,
  { name: string; px: number; box: string; text: string; dot: string; glyph: string }
> = {
  xs: { name: "Extra small", px: 24, box: "h-6 w-6", text: "text-[0.6rem]", dot: "h-2 w-2", glyph: "h-3.5 w-3.5" },
  sm: { name: "Small", px: 32, box: "h-8 w-8", text: "text-[0.7rem]", dot: "h-2.5 w-2.5", glyph: "h-4 w-4" },
  md: { name: "Medium", px: 40, box: "h-10 w-10", text: "text-sm", dot: "h-3 w-3", glyph: "h-5 w-5" },
  lg: { name: "Large", px: 56, box: "h-14 w-14", text: "text-lg", dot: "h-3.5 w-3.5", glyph: "h-6 w-6" },
  xl: { name: "Extra large", px: 80, box: "h-20 w-20", text: "text-2xl", dot: "h-4 w-4", glyph: "h-9 w-9" },
};

const STATUS_MAP: Record<PresenceStatus, { label: string; dotBg: string }> = {
  online: { label: "Online", dotBg: "bg-emerald-500" },
  away: { label: "Away", dotBg: "bg-amber-500" },
  busy: { label: "Busy", dotBg: "bg-rose-500" },
  offline: { label: "Offline", dotBg: "bg-zinc-400 dark:bg-zinc-600" },
};

const FALLBACK_TONES = [
  "bg-indigo-100 text-indigo-700 dark:bg-indigo-500/20 dark:text-indigo-300",
  "bg-violet-100 text-violet-700 dark:bg-violet-500/20 dark:text-violet-300",
  "bg-emerald-100 text-emerald-700 dark:bg-emerald-500/20 dark:text-emerald-300",
  "bg-rose-100 text-rose-700 dark:bg-rose-500/20 dark:text-rose-300",
  "bg-amber-100 text-amber-700 dark:bg-amber-500/20 dark:text-amber-300",
  "bg-sky-100 text-sky-700 dark:bg-sky-500/20 dark:text-sky-300",
];

const BROKEN_SRC = "/img/gallery/g99-missing.webp";

const EASE: [number, number, number, number] = [0.22, 1, 0.36, 1];

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

function toneFor(name: string): string {
  let hash = 0;
  for (let i = 0; i < name.length; i += 1) {
    hash = (hash * 31 + name.charCodeAt(i)) >>> 0;
  }
  return FALLBACK_TONES[hash % FALLBACK_TONES.length];
}

function UserGlyph({ className }: { className?: string }) {
  return (
    <svg
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      strokeWidth={1.7}
      strokeLinecap="round"
      strokeLinejoin="round"
      aria-hidden="true"
      className={className}
    >
      <circle cx="12" cy="8.5" r="3.6" />
      <path d="M4.5 20a7.5 7.5 0 0 1 15 0" />
    </svg>
  );
}

function StatusDot({ status, sizeClass }: { status: PresenceStatus; sizeClass: string }) {
  const cfg = STATUS_MAP[status];
  const isOnline = status === "online";
  return (
    <span
      className={`absolute bottom-0 right-0 rounded-full ring-2 ring-white dark:ring-zinc-900 ${sizeClass} ${cfg.dotBg} ${
        isOnline ? "avbasic-ping text-emerald-500" : ""
      }`}
    />
  );
}

interface AvatarProps {
  person: Person;
  size: AvatarSize;
  showStatus: boolean;
  broken: boolean;
}

function Avatar({ person, size, showStatus, broken }: AvatarProps) {
  const [errored, setErrored] = useState(false);
  const src = broken ? BROKEN_SRC : person.src;

  useEffect(() => {
    setErrored(false);
  }, [src]);

  const dims = SIZE_MAP[size];
  const useImage = src.length > 0 && !errored;
  const initials = getInitials(person.name);
  const baseName = person.name.trim().length > 0 ? person.name : "Unknown member";
  const label = showStatus ? `${baseName}, ${STATUS_MAP[person.status].label}` : baseName;

  return (
    <span
      role="img"
      aria-label={label}
      className={`relative inline-flex shrink-0 select-none ${dims.box}`}
    >
      <span
        className={`flex h-full w-full items-center justify-center overflow-hidden rounded-full font-semibold ring-1 ring-inset ring-zinc-950/10 transition-[width,height] duration-300 ease-out dark:ring-white/10 ${
          useImage ? "bg-zinc-100 dark:bg-zinc-800" : toneFor(person.name)
        }`}
      >
        {useImage ? (
          <>
            {/* eslint-disable-next-line @next/next/no-img-element */}
            <img
              src={src}
              alt=""
              loading="lazy"
              draggable={false}
              onError={() => setErrored(true)}
              className="h-full w-full object-cover"
            />
          </>
        ) : (
          <span aria-hidden="true" className={`flex items-center justify-center ${dims.text}`}>
            {initials.length > 0 ? initials : <UserGlyph className={dims.glyph} />}
          </span>
        )}
      </span>
      {showStatus && <StatusDot status={person.status} sizeClass={dims.dot} />}
    </span>
  );
}

function SizePicker({ value, onChange }: { value: AvatarSize; onChange: (next: AvatarSize) => void }) {
  const refs = useRef<Array<HTMLButtonElement | null>>([]);

  const handleKey = (event: ReactKeyboardEvent<HTMLButtonElement>, index: number) => {
    let next = index;
    if (event.key === "ArrowRight" || event.key === "ArrowDown") next = (index + 1) % SIZE_ORDER.length;
    else if (event.key === "ArrowLeft" || event.key === "ArrowUp") next = (index - 1 + SIZE_ORDER.length) % SIZE_ORDER.length;
    else if (event.key === "Home") next = 0;
    else if (event.key === "End") next = SIZE_ORDER.length - 1;
    else return;
    event.preventDefault();
    onChange(SIZE_ORDER[next]);
    refs.current[next]?.focus();
  };

  return (
    <div
      role="radiogroup"
      aria-label="Avatar size"
      className="inline-flex rounded-xl border border-zinc-200 bg-zinc-50 p-1 dark:border-zinc-800 dark:bg-zinc-900"
    >
      {SIZE_ORDER.map((size, index) => {
        const selected = size === value;
        const meta = SIZE_MAP[size];
        return (
          <button
            key={size}
            ref={(node) => {
              refs.current[index] = node;
            }}
            type="button"
            role="radio"
            aria-checked={selected}
            aria-label={`${meta.name}, ${meta.px} pixels`}
            tabIndex={selected ? 0 : -1}
            onClick={() => onChange(size)}
            onKeyDown={(event) => handleKey(event, index)}
            className={`rounded-lg px-3 py-1.5 text-sm font-medium uppercase tracking-wide transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-50 dark:focus-visible:ring-offset-zinc-900 ${
              selected
                ? "bg-zinc-900 text-white shadow-sm dark:bg-zinc-100 dark:text-zinc-900"
                : "text-zinc-500 hover:bg-zinc-200/60 hover:text-zinc-800 dark:text-zinc-400 dark:hover:bg-zinc-800 dark:hover:text-zinc-100"
            }`}
          >
            {size}
          </button>
        );
      })}
    </div>
  );
}

function Switch({
  checked,
  onChange,
  label,
  hint,
}: {
  checked: boolean;
  onChange: (next: boolean) => void;
  label: string;
  hint: string;
}) {
  return (
    <button
      type="button"
      role="switch"
      aria-checked={checked}
      onClick={() => onChange(!checked)}
      className="group flex w-full items-center justify-between gap-4 rounded-xl border border-zinc-200 bg-white px-4 py-3 text-left transition-colors hover:border-zinc-300 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:border-zinc-800 dark:bg-zinc-900 dark:hover:border-zinc-700 dark:focus-visible:ring-offset-zinc-950"
    >
      <span className="min-w-0">
        <span className="block text-sm font-medium text-zinc-800 dark:text-zinc-100">{label}</span>
        <span className="block text-xs text-zinc-500 dark:text-zinc-400">{hint}</span>
      </span>
      <span
        className={`relative inline-flex h-6 w-11 shrink-0 items-center rounded-full transition-colors ${
          checked ? "bg-indigo-600 dark:bg-indigo-500" : "bg-zinc-300 dark:bg-zinc-700"
        }`}
      >
        <span
          className={`inline-block h-5 w-5 transform rounded-full bg-white shadow-sm transition-transform duration-200 ${
            checked ? "translate-x-5" : "translate-x-0.5"
          }`}
        />
      </span>
    </button>
  );
}

export default function AvBasic() {
  const reduce = useReducedMotion();
  const [size, setSize] = useState<AvatarSize>("md");
  const [showStatus, setShowStatus] = useState(true);
  const [broken, setBroken] = useState(false);
  const [expanded, setExpanded] = useState(false);

  const maxVisible = 5;
  const shown = expanded ? PEOPLE : PEOPLE.slice(0, maxVisible);
  const overflow = PEOPLE.length - maxVisible;

  const lift = reduce ? undefined : { y: -8, scale: 1.1 };

  const fallbackTrio: Array<{ person: Person; broken: boolean; caption: string }> = [
    { person: PEOPLE[0], broken: false, caption: "Photo loads" },
    { person: PEOPLE[1], broken: true, caption: "Initials fallback" },
    {
      person: { id: "guest", name: "", role: "Invited guest", src: "", status: "offline" },
      broken: true,
      caption: "Glyph fallback",
    },
  ];

  const reveal = (delay: number) =>
    reduce
      ? {}
      : {
          initial: { opacity: 0, y: 18 },
          whileInView: { opacity: 1, y: 0 },
          viewport: { once: true, amount: 0.25 },
          transition: { duration: 0.5, delay, ease: EASE },
        };

  return (
    <section className="relative w-full overflow-hidden bg-gradient-to-b from-zinc-50 to-white px-6 py-20 text-zinc-900 sm:py-24 dark:from-zinc-950 dark:to-zinc-900 dark:text-zinc-100">
      <style>{`
        @keyframes avbasic-ping {
          0% { transform: scale(1); opacity: 0.5; }
          70%, 100% { transform: scale(2.4); opacity: 0; }
        }
        .avbasic-ping::after {
          content: "";
          position: absolute;
          inset: 0;
          border-radius: 9999px;
          background: currentColor;
          animation: avbasic-ping 2.6s cubic-bezier(0, 0, 0.2, 1) infinite;
        }
        @media (prefers-reduced-motion: reduce) {
          .avbasic-ping::after { animation: none; }
        }
      `}</style>

      <div
        aria-hidden="true"
        className="pointer-events-none absolute -top-24 left-1/2 h-72 w-72 -translate-x-1/2 rounded-full bg-indigo-400/20 blur-3xl dark:bg-indigo-500/10"
      />

      <div className="relative mx-auto max-w-5xl">
        <motion.header {...reveal(0)} className="mx-auto max-w-2xl text-center">
          <p className="text-xs font-semibold uppercase tracking-[0.2em] text-indigo-600 dark:text-indigo-400">
            Component · Avatars
          </p>
          <h2 className="mt-3 text-3xl font-bold tracking-tight sm:text-4xl">
            Avatars that never break the layout
          </h2>
          <p className="mt-4 text-base leading-relaxed text-zinc-600 dark:text-zinc-400">
            Five fixed sizes, live presence indicators, and a graceful fallback to colored initials
            or a neutral glyph when an image fails to load. Use the controls to see every state.
          </p>
        </motion.header>

        <motion.div
          {...reveal(0.05)}
          className="mt-10 rounded-2xl border border-zinc-200 bg-white/70 p-5 backdrop-blur-sm sm:p-6 dark:border-zinc-800 dark:bg-zinc-900/50"
        >
          <div className="flex flex-col gap-5">
            <div className="flex flex-col gap-2">
              <span className="text-xs font-semibold uppercase tracking-wider text-zinc-500 dark:text-zinc-400">
                Size
              </span>
              <SizePicker value={size} onChange={setSize} />
            </div>
            <div className="grid gap-3 sm:grid-cols-2">
              <Switch
                checked={showStatus}
                onChange={setShowStatus}
                label="Presence status"
                hint="Show the live availability dot"
              />
              <Switch
                checked={broken}
                onChange={setBroken}
                label="Simulate broken images"
                hint="Point every source at a 404 to test fallback"
              />
            </div>
          </div>
        </motion.div>

        <motion.div {...reveal(0.1)} className="mt-10">
          <h3 className="text-sm font-semibold uppercase tracking-wider text-zinc-500 dark:text-zinc-400">
            Team roster
          </h3>
          <ul className="mt-4 grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4">
            {PEOPLE.map((person) => (
              <li
                key={person.id}
                className="flex items-center gap-3 rounded-xl border border-zinc-200 bg-white p-3 dark:border-zinc-800 dark:bg-zinc-900"
              >
                <Avatar person={person} size={size} showStatus={showStatus} broken={broken} />
                <div className="min-w-0">
                  <p className="truncate text-sm font-semibold text-zinc-900 dark:text-zinc-100">
                    {person.name}
                  </p>
                  <p className="truncate text-xs text-zinc-500 dark:text-zinc-400">{person.role}</p>
                  <span className="mt-1 inline-flex items-center gap-1.5 text-[0.7rem] font-medium text-zinc-500 dark:text-zinc-400">
                    <span className={`h-1.5 w-1.5 rounded-full ${STATUS_MAP[person.status].dotBg}`} />
                    {STATUS_MAP[person.status].label}
                  </span>
                </div>
              </li>
            ))}
          </ul>
        </motion.div>

        <div className="mt-10 grid gap-6 lg:grid-cols-2">
          <motion.div
            {...reveal(0.12)}
            className="rounded-2xl border border-zinc-200 bg-white p-6 dark:border-zinc-800 dark:bg-zinc-900"
          >
            <h3 className="text-sm font-semibold uppercase tracking-wider text-zinc-500 dark:text-zinc-400">
              Stacked group
            </h3>
            <p className="mt-1 text-sm text-zinc-600 dark:text-zinc-400">
              8 people in the Aurora design system core team.
            </p>
            <div className="mt-5 flex items-center">
              {shown.map((person) => (
                <span key={person.id} className="group relative">
                  <motion.button
                    type="button"
                    whileHover={lift}
                    whileFocus={lift}
                    aria-label={`${person.name}, ${person.role}`}
                    className="relative -ml-3 block rounded-full ring-2 ring-white first:ml-0 hover:z-20 focus-visible:z-20 focus-visible:outline-none focus-visible:ring-indigo-500 dark:ring-zinc-900"
                  >
                    <Avatar person={person} size="md" showStatus={false} broken={broken} />
                  </motion.button>
                  <span className="pointer-events-none absolute -top-9 left-1/2 z-30 -translate-x-1/2 whitespace-nowrap rounded-md bg-zinc-900 px-2 py-1 text-xs font-medium text-white opacity-0 shadow-md transition-opacity duration-150 group-hover:opacity-100 group-focus-within:opacity-100 dark:bg-zinc-100 dark:text-zinc-900">
                    {person.name}
                  </span>
                </span>
              ))}
              {overflow > 0 && (
                <button
                  type="button"
                  aria-expanded={expanded}
                  aria-label={expanded ? "Collapse the group" : `Show ${overflow} more collaborators`}
                  onClick={() => setExpanded((value) => !value)}
                  className="relative -ml-3 flex h-10 w-10 items-center justify-center rounded-full bg-zinc-200 text-xs font-semibold text-zinc-700 ring-2 ring-white transition-colors hover:bg-zinc-300 hover:z-20 focus-visible:z-20 focus-visible:outline-none focus-visible:ring-indigo-500 dark:bg-zinc-800 dark:text-zinc-200 dark:ring-zinc-900 dark:hover:bg-zinc-700"
                >
                  {expanded ? "−" : `+${overflow}`}
                </button>
              )}
            </div>
          </motion.div>

          <motion.div
            {...reveal(0.16)}
            className="rounded-2xl border border-zinc-200 bg-white p-6 dark:border-zinc-800 dark:bg-zinc-900"
          >
            <h3 className="text-sm font-semibold uppercase tracking-wider text-zinc-500 dark:text-zinc-400">
              Fallback ladder
            </h3>
            <p className="mt-1 text-sm text-zinc-600 dark:text-zinc-400">
              Missing images degrade to initials, then to a neutral glyph.
            </p>
            <div className="mt-5 flex items-end justify-around gap-3">
              {fallbackTrio.map((item) => (
                <div key={item.person.id} className="flex flex-col items-center gap-2 text-center">
                  <Avatar person={item.person} size="lg" showStatus={false} broken={item.broken} />
                  <span className="text-xs text-zinc-500 dark:text-zinc-400">{item.caption}</span>
                </div>
              ))}
            </div>
          </motion.div>
        </div>

        <motion.div
          {...reveal(0.2)}
          className="mt-10 rounded-2xl border border-zinc-200 bg-white p-6 dark:border-zinc-800 dark:bg-zinc-900"
        >
          <h3 className="text-sm font-semibold uppercase tracking-wider text-zinc-500 dark:text-zinc-400">
            Size scale
          </h3>
          <div className="mt-6 flex flex-wrap items-end justify-center gap-8 sm:justify-between">
            {SIZE_ORDER.map((key, index) => {
              const meta = SIZE_MAP[key];
              const person = PEOPLE[index % PEOPLE.length];
              return (
                <div key={key} className="flex flex-col items-center gap-3 text-center">
                  <Avatar person={person} size={key} showStatus={showStatus} broken={broken} />
                  <div>
                    <p className="text-xs font-semibold uppercase tracking-wide text-zinc-700 dark:text-zinc-200">
                      {key}
                    </p>
                    <p className="text-[0.7rem] text-zinc-400 dark:text-zinc-500">{meta.px}px</p>
                  </div>
                </div>
              );
            })}
          </div>
        </motion.div>
      </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 →