Web InnoventixFreeCode

Ring Avatar

Original · free

avatars with gradient ring

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

import { useMemo, useRef, useState, type KeyboardEvent } from "react";
import { motion, useReducedMotion } from "motion/react";

type Status = "online" | "away" | "offline";

type Member = {
  id: string;
  name: string;
  role: string;
  initials: string;
  status: Status;
  location: string;
  focus: string;
  ring: string;
};

type FilterKey = "all" | Status;

const MEMBERS: Member[] = [
  {
    id: "amara",
    name: "Amara Okonkwo",
    role: "Principal Engineer",
    initials: "AO",
    status: "online",
    location: "Lagos, Nigeria",
    focus: "Reviewing the migration to the new event pipeline.",
    ring: "conic-gradient(from 140deg, #6366f1, #8b5cf6, #d946ef, #6366f1)",
  },
  {
    id: "diego",
    name: "Diego Marchetti",
    role: "Product Designer",
    initials: "DM",
    status: "online",
    location: "Milan, Italy",
    focus: "Prototyping the onboarding redesign in Figma.",
    ring: "conic-gradient(from 210deg, #0ea5e9, #22d3ee, #10b981, #0ea5e9)",
  },
  {
    id: "priya",
    name: "Priya Nair",
    role: "Staff Data Scientist",
    initials: "PN",
    status: "away",
    location: "Bengaluru, India",
    focus: "Tuning the churn-prediction model — back around 3pm.",
    ring: "conic-gradient(from 90deg, #f59e0b, #f43f5e, #a855f7, #f59e0b)",
  },
  {
    id: "sofia",
    name: "Sofía Reyes",
    role: "Engineering Manager",
    initials: "SR",
    status: "online",
    location: "Mexico City, Mexico",
    focus: "Running 1:1s with the platform squad this morning.",
    ring: "conic-gradient(from 320deg, #10b981, #14b8a6, #6366f1, #10b981)",
  },
  {
    id: "kenji",
    name: "Kenji Watanabe",
    role: "Platform Architect",
    initials: "KW",
    status: "offline",
    location: "Tokyo, Japan",
    focus: "Away until Monday — on parental leave.",
    ring: "conic-gradient(from 40deg, #64748b, #94a3b8, #38bdf8, #64748b)",
  },
  {
    id: "lena",
    name: "Lena Kowalski",
    role: "UX Researcher",
    initials: "LK",
    status: "away",
    location: "Kraków, Poland",
    focus: "Synthesizing last week's usability sessions.",
    ring: "conic-gradient(from 180deg, #ec4899, #f472b6, #f59e0b, #ec4899)",
  },
  {
    id: "marcus",
    name: "Marcus Bell",
    role: "DevOps Lead",
    initials: "MB",
    status: "online",
    location: "Austin, USA",
    focus: "Rolling out the zero-downtime deploy pipeline.",
    ring: "conic-gradient(from 260deg, #f43f5e, #fb7185, #f59e0b, #f43f5e)",
  },
  {
    id: "yara",
    name: "Yara Haddad",
    role: "Frontend Engineer",
    initials: "YH",
    status: "online",
    location: "Amman, Jordan",
    focus: "Building the new avatar components in the design system.",
    ring: "conic-gradient(from 20deg, #8b5cf6, #6366f1, #22d3ee, #8b5cf6)",
  },
  {
    id: "tomas",
    name: "Tomas Nilsson",
    role: "Security Engineer",
    initials: "TN",
    status: "offline",
    location: "Stockholm, Sweden",
    focus: "Offline — quarterly security audit off-site.",
    ring: "conic-gradient(from 150deg, #0ea5e9, #6366f1, #14b8a6, #0ea5e9)",
  },
];

const STATUS_META: Record<
  Status,
  { label: string; dot: string; text: string; chip: string }
> = {
  online: {
    label: "Online",
    dot: "bg-emerald-500",
    text: "text-emerald-700 dark:text-emerald-300",
    chip: "bg-emerald-50 text-emerald-700 ring-emerald-600/20 dark:bg-emerald-500/10 dark:text-emerald-300 dark:ring-emerald-400/20",
  },
  away: {
    label: "Away",
    dot: "bg-amber-500",
    text: "text-amber-700 dark:text-amber-300",
    chip: "bg-amber-50 text-amber-700 ring-amber-600/20 dark:bg-amber-500/10 dark:text-amber-300 dark:ring-amber-400/20",
  },
  offline: {
    label: "Offline",
    dot: "bg-slate-400 dark:bg-slate-500",
    text: "text-slate-600 dark:text-slate-400",
    chip: "bg-slate-100 text-slate-600 ring-slate-500/20 dark:bg-slate-500/10 dark:text-slate-400 dark:ring-slate-400/20",
  },
};

const SIZES = {
  sm: { wrap: "h-11 w-11", pad: "p-[2px]", text: "text-xs", dot: "h-3 w-3" },
  md: { wrap: "h-16 w-16", pad: "p-[3px]", text: "text-base", dot: "h-4 w-4" },
  lg: { wrap: "h-24 w-24", pad: "p-[4px]", text: "text-2xl", dot: "h-5 w-5" },
} as const;

type SizeKey = keyof typeof SIZES;

function RingAvatar({
  member,
  size,
  spin,
  showStatus = true,
}: {
  member: Member;
  size: SizeKey;
  spin: boolean;
  showStatus?: boolean;
}) {
  const s = SIZES[size];
  return (
    <span className={`relative inline-flex ${s.wrap} ${s.pad} rounded-full`}>
      <span
        aria-hidden="true"
        className="avring-spin absolute inset-0 rounded-full [will-change:transform]"
        style={{
          background: member.ring,
          animation: spin ? "avring-spin 9s linear infinite" : undefined,
        }}
      />
      <span className="relative z-10 flex h-full w-full items-center justify-center rounded-full bg-white dark:bg-slate-900">
        <span
          className={`font-semibold tracking-tight ${s.text} text-slate-700 dark:text-slate-100`}
        >
          {member.initials}
        </span>
      </span>
      {showStatus && (
        <span
          className={`absolute bottom-0 right-0 z-20 grid place-items-center rounded-full ${s.dot} ${STATUS_META[member.status].dot} ring-2 ring-white dark:ring-slate-900`}
        />
      )}
    </span>
  );
}

function PinIcon() {
  return (
    <svg
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      strokeWidth={1.6}
      strokeLinecap="round"
      strokeLinejoin="round"
      className="h-4 w-4 shrink-0"
      aria-hidden="true"
    >
      <path d="M12 21s-6-5.4-6-10a6 6 0 1 1 12 0c0 4.6-6 10-6 10Z" />
      <circle cx="12" cy="11" r="2.2" />
    </svg>
  );
}

function FocusIcon() {
  return (
    <svg
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      strokeWidth={1.6}
      strokeLinecap="round"
      strokeLinejoin="round"
      className="h-4 w-4 shrink-0"
      aria-hidden="true"
    >
      <circle cx="12" cy="12" r="8.5" />
      <circle cx="12" cy="12" r="3.4" />
      <path d="M12 1.5V4M12 20v2.5M1.5 12H4M20 12h2.5" />
    </svg>
  );
}

export default function AvRing() {
  const prefersReduced = useReducedMotion();
  const [animate, setAnimate] = useState(true);
  const [statusFilter, setStatusFilter] = useState<FilterKey>("all");
  const [selectedId, setSelectedId] = useState<string>(MEMBERS[0].id);
  const [activeIndex, setActiveIndex] = useState(0);

  const spin = animate && !prefersReduced;

  const gridRefs = useRef<Array<HTMLButtonElement | null>>([]);
  const filterRefs = useRef<Array<HTMLButtonElement | null>>([]);

  const filters = useMemo(() => {
    const counts: Record<FilterKey, number> = {
      all: MEMBERS.length,
      online: 0,
      away: 0,
      offline: 0,
    };
    for (const m of MEMBERS) counts[m.status] += 1;
    return [
      { key: "all" as FilterKey, label: "Everyone", count: counts.all },
      { key: "online" as FilterKey, label: "Online", count: counts.online },
      { key: "away" as FilterKey, label: "Away", count: counts.away },
      { key: "offline" as FilterKey, label: "Offline", count: counts.offline },
    ];
  }, []);

  const filtered = useMemo(
    () =>
      statusFilter === "all"
        ? MEMBERS
        : MEMBERS.filter((m) => m.status === statusFilter),
    [statusFilter],
  );

  const online = useMemo(() => MEMBERS.filter((m) => m.status === "online"), []);
  const stackVisible = online.slice(0, 4);
  const stackOverflow = online.length - stackVisible.length;

  const selected = MEMBERS.find((m) => m.id === selectedId) ?? MEMBERS[0];
  const safeActive = Math.min(activeIndex, filtered.length - 1);

  function selectMember(id: string) {
    setSelectedId(id);
  }

  function changeFilter(key: FilterKey) {
    setStatusFilter(key);
    setActiveIndex(0);
  }

  function moveGridFocus(next: number) {
    const count = filtered.length;
    const idx = ((next % count) + count) % count;
    setActiveIndex(idx);
    gridRefs.current[idx]?.focus();
  }

  function onGridKey(e: KeyboardEvent<HTMLButtonElement>, i: number) {
    switch (e.key) {
      case "ArrowRight":
      case "ArrowDown":
        e.preventDefault();
        moveGridFocus(i + 1);
        break;
      case "ArrowLeft":
      case "ArrowUp":
        e.preventDefault();
        moveGridFocus(i - 1);
        break;
      case "Home":
        e.preventDefault();
        moveGridFocus(0);
        break;
      case "End":
        e.preventDefault();
        moveGridFocus(filtered.length - 1);
        break;
      case "Enter":
      case " ":
        e.preventDefault();
        selectMember(filtered[i].id);
        break;
      default:
        break;
    }
  }

  function onFilterKey(e: KeyboardEvent<HTMLButtonElement>, i: number) {
    let next = i;
    if (e.key === "ArrowRight" || e.key === "ArrowDown")
      next = (i + 1) % filters.length;
    else if (e.key === "ArrowLeft" || e.key === "ArrowUp")
      next = (i - 1 + filters.length) % filters.length;
    else if (e.key === "Home") next = 0;
    else if (e.key === "End") next = filters.length - 1;
    else return;
    e.preventDefault();
    changeFilter(filters[next].key);
    filterRefs.current[next]?.focus();
  }

  return (
    <section className="relative w-full overflow-hidden bg-slate-50 px-5 py-20 text-slate-900 sm:px-8 sm:py-28 dark:bg-slate-950 dark:text-slate-100">
      <style>{`
        @keyframes avring-spin { to { transform: rotate(1turn); } }
        @keyframes avring-pulse {
          0% { opacity: .55; transform: scale(1); }
          70% { opacity: 0; transform: scale(2.1); }
          100% { opacity: 0; transform: scale(2.1); }
        }
        @media (prefers-reduced-motion: reduce) {
          .avring-spin, .avring-pulse { animation: none !important; }
        }
      `}</style>

      {/* atmosphere */}
      <div
        aria-hidden="true"
        className="pointer-events-none absolute -top-24 left-1/2 h-72 w-[42rem] max-w-[90%] -translate-x-1/2 rounded-full bg-gradient-to-r from-indigo-300/40 via-violet-300/30 to-sky-300/40 blur-3xl dark:from-indigo-600/20 dark:via-violet-600/15 dark:to-sky-600/20"
      />

      <div className="relative mx-auto max-w-5xl">
        {/* header */}
        <div className="max-w-2xl">
          <span className="inline-flex items-center gap-2 rounded-full bg-white px-3 py-1 text-xs font-medium uppercase tracking-[0.18em] text-indigo-600 ring-1 ring-slate-900/5 dark:bg-slate-900 dark:text-indigo-300 dark:ring-white/10">
            <span className="h-1.5 w-1.5 rounded-full bg-indigo-500" />
            Design system · Avatars
          </span>
          <h2 className="mt-5 text-3xl font-semibold tracking-tight sm:text-4xl">
            Who&rsquo;s building right now
          </h2>
          <p className="mt-3 text-base leading-relaxed text-slate-600 dark:text-slate-400">
            A live team directory. Every avatar wears a rotating gradient ring —
            pick one to see what they&rsquo;re focused on today.
          </p>
        </div>

        {/* toolbar */}
        <div className="mt-8 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
          <div
            role="radiogroup"
            aria-label="Filter team by status"
            className="inline-flex flex-wrap items-center gap-1 rounded-2xl bg-white p-1 ring-1 ring-slate-900/5 dark:bg-slate-900 dark:ring-white/10"
          >
            {filters.map((f, i) => {
              const checked = statusFilter === f.key;
              return (
                <button
                  key={f.key}
                  ref={(el) => {
                    filterRefs.current[i] = el;
                  }}
                  type="button"
                  role="radio"
                  aria-checked={checked}
                  tabIndex={checked ? 0 : -1}
                  onClick={() => changeFilter(f.key)}
                  onKeyDown={(e) => onFilterKey(e, i)}
                  className={`inline-flex items-center gap-2 rounded-xl px-3 py-1.5 text-sm font-medium transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-slate-900 ${
                    checked
                      ? "bg-slate-900 text-white shadow-sm dark:bg-white dark:text-slate-900"
                      : "text-slate-600 hover:bg-slate-100 dark:text-slate-300 dark:hover:bg-white/5"
                  }`}
                >
                  {f.label}
                  <span
                    className={`rounded-full px-1.5 py-0.5 text-[0.65rem] font-semibold tabular-nums ${
                      checked
                        ? "bg-white/20 text-white dark:bg-slate-900/10 dark:text-slate-900"
                        : "bg-slate-100 text-slate-500 dark:bg-white/10 dark:text-slate-400"
                    }`}
                  >
                    {f.count}
                  </span>
                </button>
              );
            })}
          </div>

          <label className="inline-flex select-none items-center gap-3 text-sm font-medium text-slate-600 dark:text-slate-300">
            <span>Animate rings</span>
            <button
              type="button"
              role="switch"
              aria-checked={animate}
              aria-label="Animate gradient rings"
              onClick={() => setAnimate((v) => !v)}
              className={`relative inline-flex h-6 w-11 shrink-0 items-center rounded-full transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-50 dark:focus-visible:ring-offset-slate-950 ${
                animate
                  ? "bg-indigo-600 dark:bg-indigo-500"
                  : "bg-slate-300 dark:bg-slate-700"
              }`}
            >
              <span
                className={`inline-block h-5 w-5 transform rounded-full bg-white shadow-sm transition-transform ${
                  animate ? "translate-x-[22px]" : "translate-x-0.5"
                }`}
              />
            </button>
          </label>
        </div>

        {/* online-now stack */}
        <div className="mt-8 flex items-center gap-4 rounded-2xl bg-white/70 px-4 py-3 ring-1 ring-slate-900/5 backdrop-blur dark:bg-white/5 dark:ring-white/10">
          <div className="flex items-center">
            {stackVisible.map((m, i) => (
              <button
                key={m.id}
                type="button"
                onClick={() => selectMember(m.id)}
                aria-label={`Select ${m.name}, ${m.role}`}
                style={{ zIndex: stackVisible.length - i }}
                className={`relative rounded-full ring-2 ring-white transition-transform hover:-translate-y-1 focus:outline-none focus-visible:z-10 focus-visible:ring-2 focus-visible:ring-indigo-500 dark:ring-slate-950 ${
                  i === 0 ? "" : "-ml-3"
                }`}
              >
                <RingAvatar member={m} size="sm" spin={spin} showStatus={false} />
              </button>
            ))}
            {stackOverflow > 0 && (
              <button
                type="button"
                onClick={() => changeFilter("online")}
                aria-label={`Show ${stackOverflow} more online`}
                className="relative -ml-3 grid h-11 w-11 place-items-center rounded-full bg-slate-100 text-xs font-semibold text-slate-600 ring-2 ring-white transition-transform hover:-translate-y-1 focus:outline-none focus-visible:z-10 focus-visible:ring-2 focus-visible:ring-indigo-500 dark:bg-slate-800 dark:text-slate-300 dark:ring-slate-950"
              >
                +{stackOverflow}
              </button>
            )}
          </div>
          <div className="min-w-0">
            <p className="text-sm font-semibold">{online.length} online now</p>
            <p className="truncate text-xs text-slate-500 dark:text-slate-400">
              {online.map((m) => m.name.split(" ")[0]).join(", ")}
            </p>
          </div>
        </div>

        {/* main */}
        <div className="mt-8 grid gap-6 lg:grid-cols-[1fr_20rem]">
          {/* directory */}
          <div
            role="listbox"
            aria-label="Team members"
            className="grid grid-cols-2 gap-2 sm:grid-cols-3"
          >
            {filtered.map((m, i) => {
              const isSelected = m.id === selectedId;
              return (
                <motion.button
                  key={m.id}
                  ref={(el: HTMLButtonElement | null) => {
                    gridRefs.current[i] = el;
                  }}
                  type="button"
                  role="option"
                  aria-selected={isSelected}
                  tabIndex={i === safeActive ? 0 : -1}
                  onClick={() => selectMember(m.id)}
                  onKeyDown={(e) => onGridKey(e, i)}
                  onFocus={() => setActiveIndex(i)}
                  initial={prefersReduced ? false : { opacity: 0, scale: 0.92 }}
                  animate={{ opacity: 1, scale: 1 }}
                  transition={{
                    duration: 0.28,
                    delay: prefersReduced ? 0 : i * 0.035,
                    ease: [0.22, 1, 0.36, 1],
                  }}
                  className={`group flex flex-col items-center gap-3 rounded-2xl border p-4 text-center transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-50 dark:focus-visible:ring-offset-slate-950 ${
                    isSelected
                      ? "border-indigo-300 bg-white shadow-sm dark:border-indigo-500/40 dark:bg-slate-900"
                      : "border-slate-200/70 bg-white/50 hover:border-slate-300 hover:bg-white dark:border-white/5 dark:bg-white/[0.02] dark:hover:border-white/15 dark:hover:bg-white/5"
                  }`}
                >
                  <span className="transition-transform duration-300 group-hover:scale-105">
                    <RingAvatar member={m} size="md" spin={spin} />
                  </span>
                  <span className="min-w-0">
                    <span className="block truncate text-sm font-semibold">
                      {m.name}
                    </span>
                    <span className="mt-0.5 block truncate text-xs text-slate-500 dark:text-slate-400">
                      {m.role}
                    </span>
                  </span>
                </motion.button>
              );
            })}
          </div>

          {/* detail */}
          <motion.aside
            key={selected.id}
            aria-live="polite"
            initial={prefersReduced ? false : { opacity: 0, y: 10 }}
            animate={{ opacity: 1, y: 0 }}
            transition={{ duration: 0.3, ease: [0.22, 1, 0.36, 1] }}
            className="flex flex-col items-center rounded-3xl border border-slate-200/70 bg-white p-6 text-center shadow-sm lg:sticky lg:top-6 dark:border-white/10 dark:bg-slate-900"
          >
            <div className="relative">
              {selected.status === "online" && (
                <span
                  aria-hidden="true"
                  className="avring-pulse absolute bottom-1 right-1 h-5 w-5 rounded-full bg-emerald-500/60"
                  style={{ animation: spin ? "avring-pulse 2.4s ease-out infinite" : undefined }}
                />
              )}
              <RingAvatar member={selected} size="lg" spin={spin} />
            </div>

            <h3 className="mt-4 text-lg font-semibold tracking-tight">
              {selected.name}
            </h3>
            <p className="text-sm text-slate-500 dark:text-slate-400">
              {selected.role}
            </p>

            <span
              className={`mt-3 inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-medium ring-1 ${STATUS_META[selected.status].chip}`}
            >
              <span
                className={`h-1.5 w-1.5 rounded-full ${STATUS_META[selected.status].dot}`}
              />
              {STATUS_META[selected.status].label}
            </span>

            <dl className="mt-6 w-full space-y-3 border-t border-slate-100 pt-5 text-left dark:border-white/5">
              <div className="flex items-start gap-2.5 text-sm text-slate-600 dark:text-slate-300">
                <span className="mt-0.5 text-slate-400 dark:text-slate-500">
                  <PinIcon />
                </span>
                <div>
                  <dt className="sr-only">Location</dt>
                  <dd>{selected.location}</dd>
                </div>
              </div>
              <div className="flex items-start gap-2.5 text-sm text-slate-600 dark:text-slate-300">
                <span className="mt-0.5 text-slate-400 dark:text-slate-500">
                  <FocusIcon />
                </span>
                <div>
                  <dt className="sr-only">Currently focused on</dt>
                  <dd>{selected.focus}</dd>
                </div>
              </div>
            </dl>
          </motion.aside>
        </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 →