Ring Avatar
Original · freeavatars with gradient ring
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"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’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’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
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

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

Squircle Avatar
Originalsquircle avatars with badge

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.

