Squircle Avatar
Original · freesquircle avatars with badge
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"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
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 quoteSimilar components
Browse all →
Basic Avatar
Originalavatars in sizes with fallback

Group Avatar
Originaloverlapping avatar group

Status Avatar
Originalavatars with online status dot

Ring Avatar
Originalavatars with gradient ring

Initials Avatar
Originalinitials avatars with colours

Shapes Avatar
Originalavatars in circle/square/squircle

Upload Avatar
Originalavatar with upload/edit overlay

Stacked Count Avatar
Originalavatar stack with +N count

With Name Avatar
Originalavatar with name and role

Spotlight Hero
OriginalA centred hero with a soft radial spotlight, badge and dual call-to-action.

Split Hero
OriginalA two-column hero pairing a headline and CTAs with a product mock and social proof.

Gradient Spotlight Hero
OriginalA minimal centred hero with a soft gradient-mesh backdrop, announcement pill and dual call-to-action buttons.

