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 (
+
+ {visibleItems.map((item, index) => {
+ const x = index * step;
+ const hoverX = index * expandedStep;
+
+ return (
+
+
+
+ );
+ })}
+
+ {remaining ? (
+
+
+ +{remaining}
+
+
+ ) : null}
+
+ );
+}
+
+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",