Web InnoventixFreeCode

With Name Avatar

Original · free

avatar with name and role

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

import { useCallback, useMemo, useRef, useState } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";

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

interface Member {
  id: string;
  name: string;
  role: string;
  email: string;
  location: string;
  bio: string;
  from: string;
  to: string;
  presence: Presence;
}

const MEMBERS: Member[] = [
  {
    id: "amara-okafor",
    name: "Amara Okafor",
    role: "Principal Product Designer",
    email: "amara.okafor@atlasand.co",
    location: "Lagos, Nigeria",
    bio: "Leads product design across the mobile apps and owns the end-to-end checkout redesign shipping this quarter.",
    from: "from-indigo-500",
    to: "to-violet-500",
    presence: "online",
  },
  {
    id: "devin-cross",
    name: "Devin Cross",
    role: "Staff Frontend Engineer",
    email: "devin.cross@atlasand.co",
    location: "Austin, USA",
    bio: "Builds the component library and keeps the render budget honest on every release, down to the last millisecond.",
    from: "from-sky-500",
    to: "to-indigo-500",
    presence: "online",
  },
  {
    id: "priya-nair",
    name: "Priya Nair",
    role: "Design Systems Lead",
    email: "priya.nair@atlasand.co",
    location: "Bengaluru, India",
    bio: "Maintains the token pipeline and the accessibility standards the whole team ships against, one audit at a time.",
    from: "from-violet-500",
    to: "to-rose-500",
    presence: "away",
  },
  {
    id: "lena-fischer",
    name: "Lena Fischer",
    role: "Senior UX Researcher",
    email: "lena.fischer@atlasand.co",
    location: "Berlin, Germany",
    bio: "Runs the research practice and turns raw interviews into decisions the product team can actually act on.",
    from: "from-emerald-500",
    to: "to-sky-500",
    presence: "online",
  },
  {
    id: "marco-bianchi",
    name: "Marco Bianchi",
    role: "Motion Designer",
    email: "marco.bianchi@atlasand.co",
    location: "Milan, Italy",
    bio: "Prototypes interaction and motion, from the smallest micro-transition to a full onboarding sequence.",
    from: "from-amber-500",
    to: "to-rose-500",
    presence: "offline",
  },
  {
    id: "theo-almeida",
    name: "Theo Almeida",
    role: "Engineering Manager",
    email: "theo.almeida@atlasand.co",
    location: "Lisbon, Portugal",
    bio: "Coaches the platform team and unblocks the roadmap one thoughtful code review at a time.",
    from: "from-rose-500",
    to: "to-amber-500",
    presence: "away",
  },
];

const PRESENCE: Record<
  Presence,
  { label: string; dot: string; ring: string; text: string }
> = {
  online: {
    label: "Online",
    dot: "bg-emerald-500",
    ring: "bg-emerald-400",
    text: "text-emerald-600 dark:text-emerald-400",
  },
  away: {
    label: "Away",
    dot: "bg-amber-500",
    ring: "bg-amber-400",
    text: "text-amber-600 dark:text-amber-400",
  },
  offline: {
    label: "Offline",
    dot: "bg-slate-400 dark:bg-slate-600",
    ring: "bg-slate-300",
    text: "text-slate-500 dark:text-slate-400",
  },
};

function initials(name: string): string {
  const parts = name.trim().split(/\s+/);
  const first = parts[0]?.[0] ?? "";
  const last = parts.length > 1 ? parts[parts.length - 1][0] : "";
  return (first + last).toUpperCase();
}

function Avatar({
  member,
  size,
}: {
  member: Member;
  size: "sm" | "lg";
}) {
  const dims =
    size === "lg" ? "h-16 w-16 text-xl" : "h-11 w-11 text-sm";
  const dotSize = size === "lg" ? "h-4 w-4" : "h-3 w-3";
  const p = PRESENCE[member.presence];
  return (
    <span className="relative inline-flex shrink-0">
      <span
        aria-hidden="true"
        className={`inline-flex items-center justify-center rounded-full bg-gradient-to-br font-semibold tracking-tight text-white shadow-sm ${member.from} ${member.to} ${dims}`}
      >
        {initials(member.name)}
      </span>
      <span
        className={`absolute bottom-0 right-0 flex items-center justify-center rounded-full ring-2 ring-white dark:ring-slate-900 ${dotSize}`}
      >
        {member.presence === "online" && (
          <span
            aria-hidden="true"
            className={`avwn-ping absolute inline-flex h-full w-full rounded-full ${p.ring}`}
          />
        )}
        <span className={`relative inline-flex rounded-full ${dotSize} ${p.dot}`} />
      </span>
    </span>
  );
}

export default function AvWithName() {
  const reduce = useReducedMotion();
  const [onlineOnly, setOnlineOnly] = useState(false);
  const [selectedId, setSelectedId] = useState<string>(MEMBERS[0].id);
  const [activeIndex, setActiveIndex] = useState(0);
  const [copied, setCopied] = useState(false);
  const optionRefs = useRef<(HTMLLIElement | null)[]>([]);
  const copyTimer = useRef<ReturnType<typeof setTimeout> | null>(null);

  const roster = useMemo(
    () => (onlineOnly ? MEMBERS.filter((m) => m.presence === "online") : MEMBERS),
    [onlineOnly]
  );

  const onlineCount = useMemo(
    () => MEMBERS.filter((m) => m.presence === "online").length,
    []
  );

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

  const selectAt = useCallback(
    (index: number) => {
      const member = roster[index];
      if (!member) return;
      setActiveIndex(index);
      setSelectedId(member.id);
    },
    [roster]
  );

  const focusAt = useCallback((index: number) => {
    optionRefs.current[index]?.focus();
  }, []);

  const onKeyDown = useCallback(
    (event: React.KeyboardEvent<HTMLUListElement>) => {
      const count = roster.length;
      if (count === 0) return;
      let next = safeActive;
      switch (event.key) {
        case "ArrowDown":
          next = (safeActive + 1) % count;
          break;
        case "ArrowUp":
          next = (safeActive - 1 + count) % count;
          break;
        case "Home":
          next = 0;
          break;
        case "End":
          next = count - 1;
          break;
        case "Enter":
        case " ":
          event.preventDefault();
          selectAt(safeActive);
          return;
        default:
          return;
      }
      event.preventDefault();
      setActiveIndex(next);
      focusAt(next);
    },
    [roster.length, safeActive, selectAt, focusAt]
  );

  const toggleOnline = useCallback(() => {
    setOnlineOnly((prev) => {
      const nextRoster = !prev
        ? MEMBERS.filter((m) => m.presence === "online")
        : MEMBERS;
      const stillThere = nextRoster.some((m) => m.id === selectedId);
      if (!stillThere && nextRoster[0]) setSelectedId(nextRoster[0].id);
      setActiveIndex(0);
      return !prev;
    });
  }, [selectedId]);

  const copyEmail = useCallback(async () => {
    try {
      if (typeof navigator !== "undefined" && navigator.clipboard) {
        await navigator.clipboard.writeText(selected.email);
      }
      setCopied(true);
      if (copyTimer.current) clearTimeout(copyTimer.current);
      copyTimer.current = setTimeout(() => setCopied(false), 2000);
    } catch {
      setCopied(false);
    }
  }, [selected.email]);

  const dur = reduce ? 0 : 0.28;

  return (
    <section className="relative w-full bg-slate-50 px-5 py-16 text-slate-900 sm:px-8 sm:py-24 dark:bg-slate-950 dark:text-slate-100">
      <style>{`
        @keyframes avwn-ping {
          0% { transform: scale(1); opacity: 0.6; }
          75%, 100% { transform: scale(2.3); opacity: 0; }
        }
        .avwn-ping { animation: avwn-ping 2s cubic-bezier(0, 0, 0.2, 1) infinite; }
        @media (prefers-reduced-motion: reduce) {
          .avwn-ping { animation: none; }
        }
      `}</style>

      <div className="mx-auto max-w-5xl">
        <header className="flex flex-col gap-6 sm:flex-row sm:items-end sm:justify-between">
          <div className="max-w-xl">
            <p className="text-xs font-semibold uppercase tracking-[0.22em] text-indigo-600 dark:text-indigo-400">
              Atlas &amp; Co.
            </p>
            <h2 className="mt-3 text-3xl font-semibold tracking-tight sm:text-4xl">
              Meet the crew
            </h2>
            <p className="mt-3 text-base leading-relaxed text-slate-600 dark:text-slate-400">
              Six people who ship the product &mdash; designers, engineers, and
              researchers working across four time zones.{" "}
              <span className="font-medium text-slate-900 dark:text-slate-200">
                {onlineCount} online now.
              </span>
            </p>
          </div>

          <button
            type="button"
            role="switch"
            aria-checked={onlineOnly}
            onClick={toggleOnline}
            className="group inline-flex items-center gap-3 self-start rounded-full border border-slate-200 bg-white px-4 py-2.5 text-sm font-medium text-slate-700 shadow-sm transition-colors hover:border-slate-300 focus:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-50 dark:border-slate-800 dark:bg-slate-900 dark:text-slate-200 dark:hover:border-slate-700 dark:focus-visible:ring-offset-slate-950"
          >
            <span>Online only</span>
            <span
              aria-hidden="true"
              className={`relative inline-flex h-6 w-11 shrink-0 items-center rounded-full transition-colors ${
                onlineOnly
                  ? "bg-emerald-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 ${
                  onlineOnly ? "translate-x-[22px]" : "translate-x-0.5"
                }`}
              />
            </span>
          </button>
        </header>

        <div className="mt-10 grid gap-6 lg:grid-cols-[minmax(0,1fr)_360px]">
          {/* Roster */}
          <div className="rounded-2xl border border-slate-200 bg-white p-2 shadow-sm dark:border-slate-800 dark:bg-slate-900">
            <ul
              role="listbox"
              aria-label="Team members"
              onKeyDown={onKeyDown}
              className="flex flex-col gap-1"
            >
              {roster.map((member, index) => {
                const isSelected = member.id === selected.id;
                const p = PRESENCE[member.presence];
                return (
                  <li
                    key={member.id}
                    ref={(el) => {
                      optionRefs.current[index] = el;
                    }}
                    id={`avwn-opt-${member.id}`}
                    role="option"
                    aria-selected={isSelected}
                    tabIndex={index === safeActive ? 0 : -1}
                    onClick={() => selectAt(index)}
                    className={`flex cursor-pointer items-center gap-4 rounded-xl border px-3 py-3 outline-none transition-colors 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 ${
                      isSelected
                        ? "border-indigo-200 bg-indigo-50 dark:border-indigo-500/40 dark:bg-indigo-500/10"
                        : "border-transparent hover:bg-slate-50 dark:hover:bg-slate-800/60"
                    }`}
                  >
                    <Avatar member={member} size="sm" />
                    <span className="min-w-0 flex-1">
                      <span className="block truncate text-sm font-semibold text-slate-900 dark:text-slate-100">
                        {member.name}
                      </span>
                      <span className="block truncate text-sm text-slate-500 dark:text-slate-400">
                        {member.role}
                      </span>
                    </span>
                    <span
                      className={`hidden shrink-0 items-center gap-1.5 text-xs font-medium sm:inline-flex ${p.text}`}
                    >
                      <span
                        aria-hidden="true"
                        className={`h-1.5 w-1.5 rounded-full ${p.dot}`}
                      />
                      {p.label}
                    </span>
                  </li>
                );
              })}
            </ul>

            {roster.length === 0 && (
              <p className="px-4 py-10 text-center text-sm text-slate-500 dark:text-slate-400">
                No one is online right now.
              </p>
            )}
          </div>

          {/* Detail */}
          <aside
            aria-live="polite"
            className="relative overflow-hidden rounded-2xl border border-slate-200 bg-white p-6 shadow-sm dark:border-slate-800 dark:bg-slate-900"
          >
            <AnimatePresence mode="wait">
              <motion.div
                key={selected.id}
                initial={reduce ? false : { opacity: 0, y: 10 }}
                animate={{ opacity: 1, y: 0 }}
                exit={reduce ? { opacity: 0 } : { opacity: 0, y: -8 }}
                transition={{ duration: dur, ease: [0.22, 1, 0.36, 1] }}
              >
                <div className="flex items-center gap-4">
                  <Avatar member={selected} size="lg" />
                  <div className="min-w-0">
                    <h3 className="truncate text-lg font-semibold tracking-tight text-slate-900 dark:text-slate-100">
                      {selected.name}
                    </h3>
                    <p className="truncate text-sm text-slate-500 dark:text-slate-400">
                      {selected.role}
                    </p>
                  </div>
                </div>

                <dl className="mt-6 space-y-3 text-sm">
                  <div className="flex items-center gap-2">
                    <dt className="sr-only">Status</dt>
                    <dd
                      className={`inline-flex items-center gap-1.5 font-medium ${PRESENCE[selected.presence].text}`}
                    >
                      <span
                        aria-hidden="true"
                        className={`h-2 w-2 rounded-full ${PRESENCE[selected.presence].dot}`}
                      />
                      {PRESENCE[selected.presence].label}
                    </dd>
                  </div>
                  <div className="flex items-start gap-2 text-slate-600 dark:text-slate-400">
                    <dt className="sr-only">Location</dt>
                    <svg
                      aria-hidden="true"
                      viewBox="0 0 24 24"
                      fill="none"
                      className="mt-0.5 h-4 w-4 shrink-0"
                    >
                      <path
                        d="M12 21s-6-5.2-6-10a6 6 0 1 1 12 0c0 4.8-6 10-6 10Z"
                        stroke="currentColor"
                        strokeWidth="1.6"
                        strokeLinecap="round"
                        strokeLinejoin="round"
                      />
                      <circle
                        cx="12"
                        cy="11"
                        r="2.2"
                        stroke="currentColor"
                        strokeWidth="1.6"
                      />
                    </svg>
                    <dd>{selected.location}</dd>
                  </div>
                </dl>

                <p className="mt-5 text-sm leading-relaxed text-slate-600 dark:text-slate-300">
                  {selected.bio}
                </p>

                <div className="mt-6 flex flex-wrap gap-2">
                  <a
                    href={`mailto:${selected.email}`}
                    className="inline-flex items-center gap-2 rounded-lg bg-slate-900 px-3.5 py-2 text-sm font-medium text-white shadow-sm transition-colors hover:bg-slate-700 focus:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:bg-white dark:text-slate-900 dark:hover:bg-slate-200 dark:focus-visible:ring-offset-slate-900"
                  >
                    <svg
                      aria-hidden="true"
                      viewBox="0 0 24 24"
                      fill="none"
                      className="h-4 w-4"
                    >
                      <rect
                        x="3"
                        y="5"
                        width="18"
                        height="14"
                        rx="2"
                        stroke="currentColor"
                        strokeWidth="1.6"
                      />
                      <path
                        d="m4 7 8 6 8-6"
                        stroke="currentColor"
                        strokeWidth="1.6"
                        strokeLinecap="round"
                        strokeLinejoin="round"
                      />
                    </svg>
                    Message
                  </a>

                  <button
                    type="button"
                    onClick={copyEmail}
                    className="inline-flex items-center gap-2 rounded-lg border border-slate-200 bg-white px-3.5 py-2 text-sm font-medium text-slate-700 shadow-sm transition-colors hover:border-slate-300 hover:bg-slate-50 focus:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:border-slate-700 dark:bg-slate-900 dark:text-slate-200 dark:hover:bg-slate-800 dark:focus-visible:ring-offset-slate-900"
                  >
                    {copied ? (
                      <>
                        <svg
                          aria-hidden="true"
                          viewBox="0 0 24 24"
                          fill="none"
                          className="h-4 w-4 text-emerald-600 dark:text-emerald-400"
                        >
                          <path
                            d="m5 12 4.5 4.5L19 7"
                            stroke="currentColor"
                            strokeWidth="1.8"
                            strokeLinecap="round"
                            strokeLinejoin="round"
                          />
                        </svg>
                        Copied
                      </>
                    ) : (
                      <>
                        <svg
                          aria-hidden="true"
                          viewBox="0 0 24 24"
                          fill="none"
                          className="h-4 w-4"
                        >
                          <rect
                            x="9"
                            y="9"
                            width="11"
                            height="11"
                            rx="2"
                            stroke="currentColor"
                            strokeWidth="1.6"
                          />
                          <path
                            d="M5 15V5a2 2 0 0 1 2-2h10"
                            stroke="currentColor"
                            strokeWidth="1.6"
                            strokeLinecap="round"
                          />
                        </svg>
                        Copy email
                      </>
                    )}
                  </button>
                </div>

                <span className="sr-only" role="status">
                  {copied ? `${selected.email} copied to clipboard` : ""}
                </span>
              </motion.div>
            </AnimatePresence>
          </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 →