From 4ed4b84402936ff90d5bc4f8f50fa88697e9b64b Mon Sep 17 00:00:00 2001 From: AbhinRustagi Date: Tue, 23 Jun 2026 14:41:01 +0530 Subject: [PATCH] feat: add avatar stack component Add an overlapping avatar group primitive with hover fan-out motion and a compact preview for team presence states. Co-authored-by: Cursor --- components/motion/avatar-stack.tsx | 151 ++++++++++++++++++ components/previews/index.tsx | 3 + .../previews/motion/avatar-stack.preview.tsx | 23 +++ lib/registry.ts | 7 + 4 files changed, 184 insertions(+) create mode 100644 components/motion/avatar-stack.tsx create mode 100644 components/previews/motion/avatar-stack.preview.tsx diff --git a/components/motion/avatar-stack.tsx b/components/motion/avatar-stack.tsx new file mode 100644 index 0000000..036012a --- /dev/null +++ b/components/motion/avatar-stack.tsx @@ -0,0 +1,151 @@ +"use client"; + +import { motion, useReducedMotion } from "motion/react"; +import type { ReactNode } from "react"; +import { SPRING_LAYOUT, SPRING_PRESS } from "@/lib/ease"; +import { useHoverCapable } from "@/lib/hooks/use-hover-capable"; +import { cn } from "@/lib/utils"; + +export type AvatarStackItem = { + id: string; + name: string; + src?: string; + fallback?: ReactNode; + className?: string; +}; + +export interface AvatarStackProps { + items: AvatarStackItem[]; + className?: string; + itemClassName?: string; + size?: number; + max?: number; + overlap?: number; + spreadOnHover?: boolean; +} + +export function AvatarStack({ + items, + className, + itemClassName, + size = 44, + max = 5, + overlap = 14, + spreadOnHover = true, +}: AvatarStackProps) { + const reduce = useReducedMotion(); + const canHover = useHoverCapable(); + const visibleItems = items.slice(0, max); + const remaining = Math.max(items.length - visibleItems.length, 0); + const shouldSpread = spreadOnHover && canHover && !reduce; + const step = Math.max(size - overlap, 10); + const expandedStep = size + 6; + const width = visibleItems.length + ? size + Math.max(visibleItems.length - 1, 0) * step + (remaining ? step : 0) + : 0; + + return ( + + ); +} + +function AvatarStackItemView({ + item, + size, + className, +}: { + item: AvatarStackItem; + size: number; + className?: string; +}) { + const fallback = + item.fallback ?? + item.name + .split(" ") + .map((part) => part[0]) + .join("") + .slice(0, 2) + .toUpperCase(); + + return ( + + {item.src ? ( + + ) : ( + {fallback} + )} + + ); +} diff --git a/components/previews/index.tsx b/components/previews/index.tsx index 88b30f4..16eb936 100644 --- a/components/previews/index.tsx +++ b/components/previews/index.tsx @@ -62,6 +62,9 @@ export const previews: Record = { "motion/animated-badge": dynamic(() => import("./motion/animated-badge.preview").then((m) => m.AnimatedBadgePreview), ), + "motion/avatar-stack": dynamic(() => + import("./motion/avatar-stack.preview").then((m) => m.AvatarStackPreview), + ), "motion/animated-toast-stack": dynamic(() => import("./motion/animated-toast-stack.preview").then((m) => m.AnimatedToastStackPreview), ), diff --git a/components/previews/motion/avatar-stack.preview.tsx b/components/previews/motion/avatar-stack.preview.tsx new file mode 100644 index 0000000..9640a14 --- /dev/null +++ b/components/previews/motion/avatar-stack.preview.tsx @@ -0,0 +1,23 @@ +"use client"; + +import { AvatarStack } from "@/components/motion/avatar-stack"; + +const TEAM = [ + { id: "maya", name: "Maya Patel", fallback: "MP", className: "bg-violet-500/15 text-violet-700 dark:text-violet-300" }, + { id: "alex", name: "Alex Kim", fallback: "AK", className: "bg-sky-500/15 text-sky-700 dark:text-sky-300" }, + { id: "nina", name: "Nina Chen", fallback: "NC", className: "bg-emerald-500/15 text-emerald-700 dark:text-emerald-300" }, + { id: "omar", name: "Omar Diaz", fallback: "OD", className: "bg-amber-500/15 text-amber-700 dark:text-amber-300" }, + { id: "zoe", name: "Zoe Hart", fallback: "ZH", className: "bg-rose-500/15 text-rose-700 dark:text-rose-300" }, + { id: "liam", name: "Liam Scott", fallback: "LS", className: "bg-fuchsia-500/15 text-fuchsia-700 dark:text-fuchsia-300" }, +]; + +export function AvatarStackPreview() { + return ( +
+ +

+ 6 collaborators live in this thread +

+
+ ); +} diff --git a/lib/registry.ts b/lib/registry.ts index cec5ace..b3eb379 100644 --- a/lib/registry.ts +++ b/lib/registry.ts @@ -205,6 +205,13 @@ export const registry: CategoryEntry[] = [ description: "Status badge with animated state icons, pulse feedback and compact size variants.", file: "components/motion/animated-badge.tsx", }, + { + slug: "avatar-stack", + name: "Avatar Stack", + description: "Overlapping avatar group that fans out on hover with spring-driven spacing and a trailing +N counter.", + file: "components/motion/avatar-stack.tsx", + badge: "new", + }, { slug: "action-swap", name: "Action Swap",