Web InnoventixFreeCode

Squircle Avatar

Original · free

squircle avatars with badge

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

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

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

type Member = {
  id: string;
  name: string;
  role: string;
  initials: string;
  gradient: string;
  status: Status;
  unread: number;
};

const STATUS: Record<
  Status,
  { label: string; dot: string; soft: string; softText: string; ring: string; note: string }
> = {
  online: {
    label: "Online",
    dot: "bg-emerald-500",
    soft: "bg-emerald-100 dark:bg-emerald-500/15",
    softText: "text-emerald-700 dark:text-emerald-300",
    ring: "ring-emerald-500",
    note: "Active now",
  },
  away: {
    label: "Away",
    dot: "bg-amber-500",
    soft: "bg-amber-100 dark:bg-amber-500/15",
    softText: "text-amber-700 dark:text-amber-300",
    ring: "ring-amber-500",
    note: "Away · idle 12m",
  },
  busy: {
    label: "Busy",
    dot: "bg-rose-500",
    soft: "bg-rose-100 dark:bg-rose-500/15",
    softText: "text-rose-700 dark:text-rose-300",
    ring: "ring-rose-500",
    note: "In a meeting",
  },
  offline: {
    label: "Offline",
    dot: "bg-slate-400 dark:bg-slate-500",
    soft: "bg-slate-100 dark:bg-slate-500/15",
    softText: "text-slate-600 dark:text-slate-400",
    ring: "ring-slate-400",
    note: "Last seen 2h ago",
  },
};

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

const INITIAL_MEMBERS: Member[] = [
  {
    id: "amara",
    name: "Amara Okonkwo",
    role: "Product Designer",
    initials: "AO",
    gradient: "from-indigo-500 to-violet-600",
    status: "online",
    unread: 0,
  },
  {
    id: "dmitri",
    name: "Dmitri Volkov",
    role: "Staff Engineer",
    initials: "DV",
    gradient: "from-sky-500 to-indigo-600",
    status: "busy",
    unread: 3,
  },
  {
    id: "priya",
    name: "Priya Nair",
    role: "Engineering Manager",
    initials: "PN",
    gradient: "from-emerald-500 to-sky-600",
    status: "online",
    unread: 0,
  },
  {
    id: "marco",
    name: "Marco Bellini",
    role: "Data Scientist",
    initials: "MB",
    gradient: "from-amber-500 to-rose-600",
    status: "away",
    unread: 0,
  },
  {
    id: "lena",
    name: "Lena Fischer",
    role: "Frontend Engineer",
    initials: "LF",
    gradient: "from-rose-500 to-violet-600",
    status: "offline",
    unread: 0,
  },
  {
    id: "tunde",
    name: "Tunde Adebayo",
    role: "Product Manager",
    initials: "TA",
    gradient: "from-violet-500 to-indigo-600",
    status: "online",
    unread: 7,
  },
];

// Superellipse (squircle) polygon in objectBoundingBox space, computed once.
function buildSquircle(samples: number, n: number): string {
  const pts: string[] = [];
  for (let i = 0; i < samples; i += 1) {
    const t = (i / samples) * Math.PI * 2;
    const cos = Math.cos(t);
    const sin = Math.sin(t);
    const x = 0.5 + 0.5 * Math.sign(cos) * Math.pow(Math.abs(cos), 2 / n);
    const y = 0.5 + 0.5 * Math.sign(sin) * Math.pow(Math.abs(sin), 2 / n);
    pts.push(`${(x * 100).toFixed(2)}% ${(y * 100).toFixed(2)}%`);
  }
  return `polygon(${pts.join(", ")})`;
}

const SQUIRCLE = buildSquircle(56, 4);

export default function AvSquircle() {
  const reduce = useReducedMotion();
  const headingId = useId();
  const groupId = useId();

  const [members, setMembers] = useState<Member[]>(INITIAL_MEMBERS);
  const [selected, setSelected] = useState<number>(0);
  const btnRefs = useRef<(HTMLButtonElement | null)[]>([]);

  const current = members[selected];
  const onlineCount = members.filter((m) => m.status === "online").length;

  function focusAt(index: number) {
    const len = members.length;
    const next = ((index % len) + len) % len;
    setSelected(next);
    btnRefs.current[next]?.focus();
  }

  function onGroupKeyDown(e: KeyboardEvent<HTMLDivElement>) {
    switch (e.key) {
      case "ArrowRight":
      case "ArrowDown":
        e.preventDefault();
        focusAt(selected + 1);
        break;
      case "ArrowLeft":
      case "ArrowUp":
        e.preventDefault();
        focusAt(selected - 1);
        break;
      case "Home":
        e.preventDefault();
        focusAt(0);
        break;
      case "End":
        e.preventDefault();
        focusAt(members.length - 1);
        break;
      default:
        break;
    }
  }

  function setStatus(status: Status) {
    setMembers((prev) =>
      prev.map((m, i) => (i === selected ? { ...m, status } : m)),
    );
  }

  return (
    <section
      aria-labelledby={headingId}
      className="relative w-full bg-slate-50 px-4 py-16 text-slate-900 sm:py-20 dark:bg-slate-950 dark:text-slate-100"
    >
      <style>{`
        @keyframes avsq-pulse {
          0% { box-shadow: 0 0 0 0 rgba(16, 185, 129, 0.55); }
          70% { box-shadow: 0 0 0 7px rgba(16, 185, 129, 0); }
          100% { box-shadow: 0 0 0 0 rgba(16, 185, 129, 0); }
        }
        .avsq-live-dot { animation: avsq-pulse 2.4s ease-out infinite; }
        @media (prefers-reduced-motion: reduce) {
          .avsq-live-dot { animation: none !important; }
        }
      `}</style>

      <div className="mx-auto max-w-5xl">
        <header className="mb-8 flex flex-col gap-3 sm:mb-10 sm:flex-row sm:items-end sm:justify-between">
          <div>
            <p className="text-xs font-semibold uppercase tracking-[0.18em] text-indigo-600 dark:text-indigo-400">
              Aurora Design System
            </p>
            <h2
              id={headingId}
              className="mt-2 text-2xl font-bold tracking-tight text-slate-900 sm:text-3xl dark:text-white"
            >
              Workspace presence
            </h2>
            <p className="mt-2 max-w-md text-sm text-slate-600 dark:text-slate-400">
              Select a teammate to view details and update their availability.
              Use arrow keys to move between avatars.
            </p>
          </div>
          <span className="inline-flex w-fit items-center gap-2 rounded-full border border-emerald-200 bg-emerald-50 px-3 py-1.5 text-sm font-medium text-emerald-700 dark:border-emerald-500/30 dark:bg-emerald-500/10 dark:text-emerald-300">
            <span className="h-2 w-2 rounded-full bg-emerald-500" aria-hidden />
            {onlineCount} online now
          </span>
        </header>

        <div className="grid gap-6 lg:grid-cols-[1fr_320px]">
          {/* Avatar radiogroup */}
          <div className="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm sm:p-6 dark:border-slate-800 dark:bg-slate-900">
            <div className="mb-4 flex items-center justify-between">
              <h3 className="text-sm font-semibold text-slate-700 dark:text-slate-300">
                Team ({members.length})
              </h3>
              <span className="text-xs text-slate-400 dark:text-slate-500">
                {STATUS[current.status].note}
              </span>
            </div>

            <div
              role="radiogroup"
              aria-labelledby={groupId}
              onKeyDown={onGroupKeyDown}
              className="flex flex-wrap gap-4 sm:gap-5"
            >
              <span id={groupId} className="sr-only">
                Team members. Use the arrow keys to select a teammate.
              </span>

              {members.map((m, i) => {
                const isSel = i === selected;
                const s = STATUS[m.status];
                const ariaLabel =
                  `${m.name}, ${m.role}, ${s.label}` +
                  (m.unread > 0 ? `, ${m.unread} unread messages` : "");

                return (
                  <motion.button
                    key={m.id}
                    ref={(el) => {
                      btnRefs.current[i] = el;
                    }}
                    type="button"
                    role="radio"
                    aria-checked={isSel}
                    aria-label={ariaLabel}
                    tabIndex={isSel ? 0 : -1}
                    onClick={() => focusAt(i)}
                    initial={reduce ? false : { opacity: 0, y: 10, scale: 0.9 }}
                    animate={reduce ? undefined : { opacity: 1, y: 0, scale: 1 }}
                    transition={{ delay: reduce ? 0 : i * 0.05, duration: 0.35, ease: [0.22, 1, 0.36, 1] }}
                    whileTap={reduce ? undefined : { scale: 0.94 }}
                    className="group relative rounded-2xl 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"
                  >
                    {/* Squircle ring + avatar */}
                    <motion.span
                      aria-hidden
                      animate={reduce ? undefined : { scale: isSel ? 1.05 : 1 }}
                      transition={{ type: "spring", stiffness: 320, damping: 22 }}
                      style={{ clipPath: SQUIRCLE }}
                      className={[
                        "block p-[3px] transition-colors",
                        isSel
                          ? "bg-indigo-500 dark:bg-indigo-400"
                          : "bg-slate-200 group-hover:bg-slate-300 dark:bg-slate-700 dark:group-hover:bg-slate-600",
                      ].join(" ")}
                    >
                      <span
                        style={{ clipPath: SQUIRCLE }}
                        className={[
                          "flex h-16 w-16 items-center justify-center bg-gradient-to-br text-lg font-semibold text-white select-none",
                          m.gradient,
                          m.status === "offline" ? "opacity-60 saturate-50" : "",
                        ].join(" ")}
                      >
                        {m.initials}
                      </span>
                    </motion.span>

                    {/* Status badge */}
                    <span
                      aria-hidden
                      className={[
                        "absolute bottom-0.5 right-0.5 h-4 w-4 rounded-full ring-2 ring-white dark:ring-slate-900",
                        s.dot,
                        m.status === "online" && !reduce ? "avsq-live-dot" : "",
                      ].join(" ")}
                    />

                    {/* Unread count badge */}
                    {m.unread > 0 && (
                      <span
                        aria-hidden
                        className="absolute -right-1.5 -top-1.5 flex h-5 min-w-5 items-center justify-center rounded-full bg-rose-500 px-1.5 text-[11px] font-bold leading-none text-white ring-2 ring-white dark:ring-slate-900"
                      >
                        {m.unread > 9 ? "9+" : m.unread}
                      </span>
                    )}
                  </motion.button>
                );
              })}
            </div>

            <p className="mt-5 border-t border-slate-100 pt-4 text-xs text-slate-400 dark:border-slate-800 dark:text-slate-500">
              Squircle avatars use a superellipse mask for smoother, continuous
              corners than a standard rounded square.
            </p>
          </div>

          {/* Detail panel */}
          <div className="rounded-2xl border border-slate-200 bg-white p-6 shadow-sm dark:border-slate-800 dark:bg-slate-900">
            <div className="flex items-center gap-4">
              <span className="relative shrink-0">
                <span
                  aria-hidden
                  style={{ clipPath: SQUIRCLE }}
                  className={[
                    "flex h-20 w-20 items-center justify-center bg-gradient-to-br text-2xl font-bold text-white select-none",
                    current.gradient,
                    current.status === "offline" ? "opacity-60 saturate-50" : "",
                  ].join(" ")}
                >
                  {current.initials}
                </span>
                <span
                  aria-hidden
                  className={[
                    "absolute bottom-0.5 right-0.5 h-5 w-5 rounded-full ring-2 ring-white dark:ring-slate-900",
                    STATUS[current.status].dot,
                    current.status === "online" && !reduce ? "avsq-live-dot" : "",
                  ].join(" ")}
                />
              </span>
              <div className="min-w-0">
                <p className="truncate text-base font-semibold text-slate-900 dark:text-white">
                  {current.name}
                </p>
                <p className="truncate text-sm text-slate-500 dark:text-slate-400">
                  {current.role}
                </p>
                <span
                  className={[
                    "mt-2 inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-medium",
                    STATUS[current.status].soft,
                    STATUS[current.status].softText,
                  ].join(" ")}
                >
                  <span
                    className={`h-1.5 w-1.5 rounded-full ${STATUS[current.status].dot}`}
                    aria-hidden
                  />
                  {STATUS[current.status].note}
                </span>
              </div>
            </div>

            <fieldset className="mt-6">
              <legend className="mb-2 text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400">
                Set availability
              </legend>
              <div className="grid grid-cols-2 gap-2">
                {STATUS_ORDER.map((key) => {
                  const active = current.status === key;
                  const s = STATUS[key];
                  return (
                    <button
                      key={key}
                      type="button"
                      aria-pressed={active}
                      onClick={() => setStatus(key)}
                      className={[
                        "inline-flex items-center gap-2 rounded-lg border px-3 py-2 text-sm font-medium transition-colors 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",
                        active
                          ? `border-transparent ${s.soft} ${s.softText} ring-1 ${s.ring}`
                          : "border-slate-200 bg-white text-slate-600 hover:bg-slate-50 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-300 dark:hover:bg-slate-700/60",
                      ].join(" ")}
                    >
                      <span className={`h-2 w-2 rounded-full ${s.dot}`} aria-hidden />
                      {s.label}
                    </button>
                  );
                })}
              </div>
            </fieldset>

            <button
              type="button"
              className="mt-6 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 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"
            >
              <svg
                viewBox="0 0 24 24"
                fill="none"
                stroke="currentColor"
                strokeWidth={1.8}
                strokeLinecap="round"
                strokeLinejoin="round"
                className="h-4 w-4"
                aria-hidden
              >
                <path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
              </svg>
              Message {current.name.split(" ")[0]}
              {current.unread > 0 && (
                <span className="ml-1 inline-flex h-5 min-w-5 items-center justify-center rounded-full bg-white/25 px-1.5 text-xs font-bold">
                  {current.unread}
                </span>
              )}
            </button>
          </div>
        </div>

        {/* Accessible announcement of status changes */}
        <p role="status" aria-live="polite" className="sr-only">
          {current.name} is now {STATUS[current.status].label}.
        </p>
      </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 →