Web InnoventixFreeCode

Status Avatar

Original · free

avatars with online status dot

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

import { useId, useRef, useState } from "react";
import type { KeyboardEvent, ReactNode } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";

type Status = "online" | "away" | "busy" | "offline";
type Filter = Status | "all";

type StatusMeta = {
  label: string;
  dot: string;
  text: string;
  chip: string;
};

const STATUS: Record<Status, StatusMeta> = {
  online: {
    label: "Online",
    dot: "bg-emerald-500",
    text: "text-emerald-600 dark:text-emerald-400",
    chip: "bg-emerald-50 text-emerald-700 ring-emerald-200 dark:bg-emerald-500/10 dark:text-emerald-300 dark:ring-emerald-500/30",
  },
  away: {
    label: "Away",
    dot: "bg-amber-500",
    text: "text-amber-600 dark:text-amber-400",
    chip: "bg-amber-50 text-amber-700 ring-amber-200 dark:bg-amber-500/10 dark:text-amber-300 dark:ring-amber-500/30",
  },
  busy: {
    label: "Busy",
    dot: "bg-rose-500",
    text: "text-rose-600 dark:text-rose-400",
    chip: "bg-rose-50 text-rose-700 ring-rose-200 dark:bg-rose-500/10 dark:text-rose-300 dark:ring-rose-500/30",
  },
  offline: {
    label: "Offline",
    dot: "bg-zinc-400 dark:bg-zinc-500",
    text: "text-zinc-500 dark:text-zinc-400",
    chip: "bg-zinc-100 text-zinc-600 ring-zinc-200 dark:bg-zinc-800 dark:text-zinc-400 dark:ring-zinc-700",
  },
};

const STATUS_ORDER: Status[] = ["online", "away", "busy", "offline"];

type Teammate = {
  id: string;
  name: string;
  role: string;
  initials: string;
  gradient: string;
  status: Status;
  note: string;
  localTime: string;
};

const TEAM: Teammate[] = [
  {
    id: "maya",
    name: "Maya Okonkwo",
    role: "Staff Product Designer",
    initials: "MO",
    gradient: "from-rose-400 to-amber-400",
    status: "online",
    note: "Active now — reviewing the checkout redesign.",
    localTime: "9:42 AM · Lagos",
  },
  {
    id: "diego",
    name: "Diego Fuentes",
    role: "Frontend Engineer",
    initials: "DF",
    gradient: "from-sky-500 to-indigo-500",
    status: "busy",
    note: "In a call until 3:30 PM. Ping for anything urgent.",
    localTime: "1:42 PM · Madrid",
  },
  {
    id: "priya",
    name: "Priya Nair",
    role: "Engineering Manager",
    initials: "PN",
    gradient: "from-violet-500 to-indigo-500",
    status: "away",
    note: "Back in about 20 minutes.",
    localTime: "6:12 PM · Bengaluru",
  },
  {
    id: "aiko",
    name: "Aiko Tanaka",
    role: "QA Lead",
    initials: "AT",
    gradient: "from-amber-400 to-rose-400",
    status: "online",
    note: "Active now — running the release regression suite.",
    localTime: "9:42 PM · Tokyo",
  },
  {
    id: "noah",
    name: "Noah Bennett",
    role: "Data Analyst",
    initials: "NB",
    gradient: "from-sky-400 to-violet-500",
    status: "away",
    note: "Out for lunch, replies after 1.",
    localTime: "11:42 AM · Austin",
  },
  {
    id: "fatima",
    name: "Fatima Zahra",
    role: "Product Manager",
    initials: "FZ",
    gradient: "from-emerald-400 to-sky-500",
    status: "online",
    note: "Active now — drafting the Q3 roadmap.",
    localTime: "1:42 PM · Casablanca",
  },
  {
    id: "tomas",
    name: "Tomas Berg",
    role: "Backend Engineer",
    initials: "TB",
    gradient: "from-emerald-500 to-sky-500",
    status: "offline",
    note: "Last seen 2 hours ago.",
    localTime: "1:42 PM · Stockholm",
  },
  {
    id: "liam",
    name: "Liam Walsh",
    role: "DevOps Engineer",
    initials: "LW",
    gradient: "from-slate-500 to-zinc-600",
    status: "offline",
    note: "Last seen yesterday at 6:14 PM.",
    localTime: "12:42 PM · Dublin",
  },
];

const FILTERS: { value: Filter; label: string }[] = [
  { value: "all", label: "Everyone" },
  { value: "online", label: "Online" },
  { value: "away", label: "Away" },
  { value: "busy", label: "Busy" },
  { value: "offline", label: "Offline" },
];

function UserGlyph() {
  return (
    <svg viewBox="0 0 24 24" className="h-6 w-6" fill="currentColor" aria-hidden="true">
      <path d="M12 12a4.25 4.25 0 1 0 0-8.5 4.25 4.25 0 0 0 0 8.5Z" />
      <path d="M4.5 19.2c0-3.14 3.36-5.2 7.5-5.2s7.5 2.06 7.5 5.2a1 1 0 0 1-1 .95H5.5a1 1 0 0 1-1-.95Z" />
    </svg>
  );
}

function ChevronGlyph({ open }: { open: boolean }) {
  return (
    <svg
      viewBox="0 0 24 24"
      className={`h-4 w-4 transition-transform duration-200 motion-reduce:transition-none ${open ? "rotate-180" : ""}`}
      fill="none"
      stroke="currentColor"
      strokeWidth={2}
      strokeLinecap="round"
      strokeLinejoin="round"
      aria-hidden="true"
    >
      <path d="m6 9 6 6 6-6" />
    </svg>
  );
}

function StatusDot({
  status,
  reduce,
  size = "sm",
}: {
  status: Status;
  reduce: boolean;
  size?: "sm" | "md";
}) {
  const dim = size === "md" ? "h-3.5 w-3.5" : "h-3 w-3";
  const bg = STATUS[status].dot;
  return (
    <span className={`relative inline-flex ${dim}`}>
      {status === "online" && !reduce ? (
        <span
          className={`avst-anim absolute inset-0 rounded-full ${bg} opacity-60 animate-[avst-ping_1.9s_cubic-bezier(0,0,0.2,1)_infinite]`}
        />
      ) : null}
      <span
        className={`relative inline-flex ${dim} rounded-full ${bg} ring-2 ring-white dark:ring-zinc-900`}
      />
    </span>
  );
}

function StatusAvatar({
  gradient,
  status,
  reduce,
  size = "md",
  children,
}: {
  gradient: string;
  status: Status;
  reduce: boolean;
  size?: "md" | "lg";
  children: ReactNode;
}) {
  const box = size === "lg" ? "h-14 w-14 text-lg" : "h-11 w-11 text-sm";
  return (
    <span className="relative inline-flex shrink-0">
      <span
        className={`inline-flex ${box} select-none items-center justify-center rounded-full bg-gradient-to-br ${gradient} font-semibold tracking-wide text-white shadow-sm`}
      >
        {children}
      </span>
      <span className="absolute -bottom-0.5 -right-0.5">
        <StatusDot status={status} reduce={reduce} size={size === "lg" ? "md" : "sm"} />
      </span>
    </span>
  );
}

export default function AvStatus() {
  const reduce = useReducedMotion() ?? false;
  const uid = useId();
  const [myStatus, setMyStatus] = useState<Status>("online");
  const [filter, setFilter] = useState<Filter>("all");
  const [openId, setOpenId] = useState<string | null>(null);
  const radioRefs = useRef<Record<Status, HTMLButtonElement | null>>({
    online: null,
    away: null,
    busy: null,
    offline: null,
  });

  const onlineCount =
    TEAM.filter((m) => m.status === "online").length + (myStatus === "online" ? 1 : 0);

  const visible = filter === "all" ? TEAM : TEAM.filter((m) => m.status === filter);
  const stack = TEAM.filter((m) => m.status === "online").slice(0, 4);
  const stackExtra = Math.max(0, onlineCount - stack.length);

  function handleRadioKey(e: KeyboardEvent<HTMLButtonElement>, current: Status) {
    const i = STATUS_ORDER.indexOf(current);
    let ni = i;
    if (e.key === "ArrowRight" || e.key === "ArrowDown") ni = (i + 1) % STATUS_ORDER.length;
    else if (e.key === "ArrowLeft" || e.key === "ArrowUp")
      ni = (i - 1 + STATUS_ORDER.length) % STATUS_ORDER.length;
    else if (e.key === "Home") ni = 0;
    else if (e.key === "End") ni = STATUS_ORDER.length - 1;
    else return;
    e.preventDefault();
    const next = STATUS_ORDER[ni];
    setMyStatus(next);
    radioRefs.current[next]?.focus();
  }

  const focusRing =
    "focus-visible: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-zinc-900";

  return (
    <section
      aria-label="Team presence"
      className="relative w-full bg-zinc-50 px-4 py-16 text-zinc-900 sm:py-20 dark:bg-zinc-950 dark:text-zinc-100"
    >
      <style>{`
        @keyframes avst-ping {
          0% { transform: scale(1); opacity: 0.55; }
          70%, 100% { transform: scale(2.3); opacity: 0; }
        }
        @media (prefers-reduced-motion: reduce) {
          .avst-anim { animation: none !important; }
        }
      `}</style>

      <motion.div
        initial={reduce ? false : { opacity: 0, y: 14 }}
        whileInView={{ opacity: 1, y: 0 }}
        viewport={{ once: true, amount: 0.3 }}
        transition={{ duration: reduce ? 0 : 0.45, ease: [0.22, 1, 0.36, 1] }}
        className="mx-auto w-full max-w-2xl overflow-hidden rounded-2xl border border-zinc-200 bg-white shadow-sm dark:border-zinc-800 dark:bg-zinc-900"
      >
        {/* Header */}
        <div className="flex flex-wrap items-start justify-between gap-4 border-b border-zinc-200 px-6 py-5 dark:border-zinc-800">
          <div>
            <h2 className="text-lg font-semibold tracking-tight">Design Systems Guild</h2>
            <p className="mt-0.5 flex items-center gap-1.5 text-sm text-zinc-500 dark:text-zinc-400">
              <span className="inline-flex h-2 w-2 rounded-full bg-emerald-500" aria-hidden="true" />
              <span aria-live="polite">
                {onlineCount} of {TEAM.length + 1} online now
              </span>
            </p>
          </div>

          <div className="flex items-center -space-x-2" aria-hidden="true">
            {stack.map((m) => (
              <span
                key={m.id}
                className={`inline-flex h-8 w-8 items-center justify-center rounded-full bg-gradient-to-br ${m.gradient} text-[11px] font-semibold text-white ring-2 ring-white dark:ring-zinc-900`}
              >
                {m.initials}
              </span>
            ))}
            {stackExtra > 0 ? (
              <span className="inline-flex h-8 w-8 items-center justify-center rounded-full bg-zinc-100 text-[11px] font-semibold text-zinc-600 ring-2 ring-white dark:bg-zinc-800 dark:text-zinc-300 dark:ring-zinc-900">
                +{stackExtra}
              </span>
            ) : null}
          </div>
        </div>

        {/* Your status */}
        <div className="border-b border-zinc-200 px-6 py-5 dark:border-zinc-800">
          <div className="flex items-center gap-3.5">
            <StatusAvatar gradient="from-indigo-500 to-violet-600" status={myStatus} reduce={reduce} size="lg">
              <UserGlyph />
            </StatusAvatar>
            <div className="min-w-0">
              <p className="truncate font-medium">You · Salman R.</p>
              <p className={`text-sm font-medium ${STATUS[myStatus].text}`}>
                {STATUS[myStatus].label}
              </p>
            </div>
          </div>

          <p className="sr-only" aria-live="polite">
            Your status is set to {STATUS[myStatus].label}.
          </p>

          <div className="mt-4">
            <p className="mb-2 text-xs font-medium uppercase tracking-wider text-zinc-500 dark:text-zinc-400">
              Set your status
            </p>
            <div
              role="radiogroup"
              aria-label="Set your status"
              className="grid grid-cols-2 gap-1.5 rounded-xl bg-zinc-100 p-1.5 sm:grid-cols-4 dark:bg-zinc-800/70"
            >
              {STATUS_ORDER.map((s) => {
                const active = myStatus === s;
                return (
                  <button
                    key={s}
                    ref={(el) => {
                      radioRefs.current[s] = el;
                    }}
                    type="button"
                    role="radio"
                    aria-checked={active}
                    tabIndex={active ? 0 : -1}
                    onClick={() => setMyStatus(s)}
                    onKeyDown={(e) => handleRadioKey(e, s)}
                    className={`flex items-center justify-center gap-2 rounded-lg px-3 py-2 text-sm font-medium transition-colors motion-reduce:transition-none ${focusRing} ${
                      active
                        ? "bg-white text-zinc-900 shadow-sm dark:bg-zinc-950 dark:text-zinc-100"
                        : "text-zinc-500 hover:text-zinc-800 dark:text-zinc-400 dark:hover:text-zinc-200"
                    }`}
                  >
                    <span
                      className={`inline-flex h-2.5 w-2.5 rounded-full ${STATUS[s].dot}`}
                      aria-hidden="true"
                    />
                    {STATUS[s].label}
                  </button>
                );
              })}
            </div>
          </div>
        </div>

        {/* Filter */}
        <div className="flex flex-wrap items-center justify-between gap-3 px-6 py-4">
          <p className="text-xs font-medium uppercase tracking-wider text-zinc-500 dark:text-zinc-400">
            Teammates
          </p>
          <div className="flex items-center gap-2">
            <label htmlFor={`${uid}-filter`} className="sr-only">
              Filter teammates by status
            </label>
            <div className="relative">
              <select
                id={`${uid}-filter`}
                value={filter}
                onChange={(e) => setFilter(e.target.value as Filter)}
                className={`appearance-none rounded-lg border border-zinc-200 bg-white py-1.5 pl-3 pr-8 text-sm font-medium text-zinc-700 hover:border-zinc-300 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-200 dark:hover:border-zinc-600 ${focusRing}`}
              >
                {FILTERS.map((f) => (
                  <option key={f.value} value={f.value}>
                    {f.label}
                  </option>
                ))}
              </select>
              <svg
                viewBox="0 0 24 24"
                className="pointer-events-none absolute right-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-zinc-400"
                fill="none"
                stroke="currentColor"
                strokeWidth={2}
                strokeLinecap="round"
                strokeLinejoin="round"
                aria-hidden="true"
              >
                <path d="m6 9 6 6 6-6" />
              </svg>
            </div>
          </div>
        </div>

        {/* Roster */}
        <ul className="px-2 pb-3">
          {visible.map((m) => {
            const open = openId === m.id;
            const panelId = `${uid}-panel-${m.id}`;
            return (
              <li key={m.id}>
                <button
                  type="button"
                  aria-expanded={open}
                  aria-controls={panelId}
                  onClick={() => setOpenId(open ? null : m.id)}
                  className={`flex w-full items-center gap-3.5 rounded-xl px-4 py-3 text-left transition-colors hover:bg-zinc-50 motion-reduce:transition-none dark:hover:bg-zinc-800/60 ${focusRing}`}
                >
                  <StatusAvatar gradient={m.gradient} status={m.status} reduce={reduce}>
                    {m.initials}
                  </StatusAvatar>
                  <span className="min-w-0 flex-1">
                    <span className="flex items-center gap-2">
                      <span className="truncate font-medium text-zinc-900 dark:text-zinc-100">
                        {m.name}
                      </span>
                      <span
                        className={`hidden shrink-0 rounded-full px-2 py-0.5 text-[11px] font-medium ring-1 ring-inset sm:inline ${STATUS[m.status].chip}`}
                      >
                        {STATUS[m.status].label}
                      </span>
                    </span>
                    <span className="mt-0.5 block truncate text-sm text-zinc-500 dark:text-zinc-400">
                      {m.role}
                    </span>
                  </span>
                  <span className="shrink-0 text-zinc-400 dark:text-zinc-500">
                    <ChevronGlyph open={open} />
                  </span>
                </button>

                <AnimatePresence initial={false}>
                  {open ? (
                    <motion.div
                      id={panelId}
                      role="region"
                      aria-label={`${m.name} details`}
                      initial={reduce ? { opacity: 1 } : { height: 0, opacity: 0 }}
                      animate={reduce ? { opacity: 1 } : { height: "auto", opacity: 1 }}
                      exit={reduce ? { opacity: 0 } : { height: 0, opacity: 0 }}
                      transition={{ duration: reduce ? 0 : 0.25, ease: [0.22, 1, 0.36, 1] }}
                      className="overflow-hidden"
                    >
                      <div className="mx-4 mb-2 rounded-xl border border-zinc-200 bg-zinc-50 px-4 py-3.5 dark:border-zinc-800 dark:bg-zinc-800/40">
                        <p className={`text-sm font-medium ${STATUS[m.status].text}`}>{m.note}</p>
                        <p className="mt-1 flex items-center gap-1.5 text-xs text-zinc-500 dark:text-zinc-400">
                          <svg
                            viewBox="0 0 24 24"
                            className="h-3.5 w-3.5"
                            fill="none"
                            stroke="currentColor"
                            strokeWidth={2}
                            strokeLinecap="round"
                            strokeLinejoin="round"
                            aria-hidden="true"
                          >
                            <circle cx="12" cy="12" r="9" />
                            <path d="M12 7v5l3 2" />
                          </svg>
                          {m.localTime}
                        </p>
                        <div className="mt-3 flex flex-wrap gap-2">
                          <button
                            type="button"
                            className={`inline-flex items-center gap-1.5 rounded-lg bg-indigo-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-indigo-500 dark:bg-indigo-500 dark:hover:bg-indigo-400 ${focusRing}`}
                          >
                            <svg
                              viewBox="0 0 24 24"
                              className="h-4 w-4"
                              fill="none"
                              stroke="currentColor"
                              strokeWidth={2}
                              strokeLinecap="round"
                              strokeLinejoin="round"
                              aria-hidden="true"
                            >
                              <path d="M21 15a2 2 0 0 1-2 2H8l-4 4V5a2 2 0 0 1 2-2h13a2 2 0 0 1 2 2Z" />
                            </svg>
                            Message
                          </button>
                          <button
                            type="button"
                            className={`inline-flex items-center gap-1.5 rounded-lg border border-zinc-300 bg-white px-3 py-1.5 text-sm font-medium text-zinc-700 hover:bg-zinc-100 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-200 dark:hover:bg-zinc-800 ${focusRing}`}
                          >
                            View profile
                          </button>
                        </div>
                      </div>
                    </motion.div>
                  ) : null}
                </AnimatePresence>
              </li>
            );
          })}

          {visible.length === 0 ? (
            <li className="px-4 py-10 text-center text-sm text-zinc-500 dark:text-zinc-400">
              No teammates are currently {filter === "all" ? "listed" : STATUS[filter as Status].label.toLowerCase()}.
            </li>
          ) : null}
        </ul>
      </motion.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 →