{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "avatar-stack",
  "title": "Avatar Stack",
  "description": "An overlapping avatar stack that fans apart on hover, springs name tooltips over each face and expands hidden members from a +N pill with a stagger",
  "dependencies": [
    "framer-motion"
  ],
  "files": [
    {
      "path": "app/registry/avatar-stack/avatar-stack.tsx",
      "content": "/**\n * Spectrum UI — AvatarStack\n *\n * An overlapping avatar row that fans apart on hover. Hovering or focusing a\n * single avatar lifts it and springs a name tooltip in above it; a \"+N\" pill\n * expands the hidden members one by one with a stagger and morphs into a\n * collapse button. Fully keyboard operable with roving focus, honors\n * prefers-reduced-motion, and announces itself as a labelled group.\n *\n * Dependencies: framer-motion, @/lib/utils\n *\n * @example\n * <AvatarStack\n *   items={[{ name: \"Ada Lovelace\" }, { name: \"Alan Turing\" }]}\n *   onAvatarClick={(item) => openProfile(item)}\n * />\n */\n\n\"use client\"\n\nimport React, { useCallback, useRef, useState } from \"react\"\nimport { AnimatePresence, motion, useReducedMotion } from \"framer-motion\"\nimport { cn } from \"@/lib/utils\"\n\n// ─── Types ───────────────────────────────────────────────────────────────────\n\nexport interface AvatarItem {\n  /** Person's display name; also used for the tooltip and initials fallback */\n  name: string\n  /** Optional image URL; initials derived from the name are shown when omitted */\n  src?: string\n}\n\nexport interface AvatarStackProps {\n  /** People to render; the first `max` are visible, the rest sit behind \"+N\" */\n  items: AvatarItem[]\n  /** Number of avatars shown before overflowing into the \"+N\" pill. Default 4 */\n  max?: number\n  /** Avatar diameter: 28, 36 or 44px. Default \"md\" */\n  size?: \"sm\" | \"md\" | \"lg\"\n  /** Whether the \"+N\" pill expands the hidden avatars on click. Default true */\n  expandable?: boolean\n  /** Fires when an avatar is clicked or activated with Enter/Space */\n  onAvatarClick?: (item: AvatarItem, index: number) => void\n  /** Additional classes merged onto the group container */\n  className?: string\n}\n\n// ─── Constants ───────────────────────────────────────────────────────────────\n\n/** Negative margin overlapping the resting stack, in px */\nconst OVERLAP_MARGIN = -10\n/** Gap between avatars once the stack fans apart on hover, in px */\nconst FAN_GAP = 4\n/** Vertical lift of a hovered/focused avatar, in px */\nconst LIFT_Y = -4\n/** Scale of a hovered/focused avatar */\nconst LIFT_SCALE = 1.1\n/** z-index applied to the lifted avatar so it clears its neighbors */\nconst LIFTED_Z_INDEX = 10\n/** Delay between each hidden avatar springing in/out, in seconds (30ms) */\nconst STAGGER_S = 0.03\n/** Distance the tooltip travels while springing in, in px */\nconst TOOLTIP_SHIFT_Y = 4\n/** Initial scale of the tooltip before it springs in */\nconst TOOLTIP_INITIAL_SCALE = 0.9\n\n/** Snappy spring for micro-interactions: lift, tooltip, pill crossfade */\nconst SPRING_SNAPPY = { type: \"spring\", stiffness: 500, damping: 30 } as const\n/** Softer spring for positional moves: fan-out and row reflow */\nconst SPRING_SOFT = { type: \"spring\", stiffness: 260, damping: 22 } as const\n/** Ease used by non-spring reveals/exits */\nconst REVEAL_EASE = [0.22, 1, 0.36, 1] as const\nconst INSTANT = { duration: 0 } as const\n\nconst SIZES = {\n  sm: { px: 28, text: \"text-[10px]\" },\n  md: { px: 36, text: \"text-xs\" },\n  lg: { px: 44, text: \"text-sm\" },\n} as const\n\nconst TOOLTIP_SHADOW =\n  \"shadow-[0px_0px_0px_1px_rgba(0,0,0,0.06),0px_1px_2px_0px_rgba(0,0,0,0.04),0px_2px_4px_0px_rgba(0,0,0,0.04)]\"\n\n/** First letters of the first two words: \"Ada Lovelace\" → \"AL\" */\nfunction getInitials(name: string) {\n  return name\n    .trim()\n    .split(/\\s+/)\n    .slice(0, 2)\n    .map((word) => word[0]?.toUpperCase() ?? \"\")\n    .join(\"\")\n}\n\n// ─── Component ───────────────────────────────────────────────────────────────\n\nexport function AvatarStack({\n  items,\n  max = 4,\n  size = \"md\",\n  expandable = true,\n  onAvatarClick,\n  className,\n}: AvatarStackProps) {\n  const shouldReduceMotion = useReducedMotion()\n  const [hovered, setHovered] = useState(false)\n  const [focusWithin, setFocusWithin] = useState(false)\n  const [activeIndex, setActiveIndex] = useState<number | null>(null)\n  const [expanded, setExpanded] = useState(false)\n  const [rovingIndex, setRovingIndex] = useState(0)\n\n  const avatarRefs = useRef<(HTMLButtonElement | null)[]>([])\n  const pillRef = useRef<HTMLButtonElement | null>(null)\n\n  const visibleMax = Math.max(1, max)\n  const overflowCount = Math.max(0, items.length - visibleMax)\n  const hasOverflow = overflowCount > 0\n  const shownItems = expanded ? items : items.slice(0, visibleMax)\n  const pillIsInteractive = hasOverflow && expandable\n  // Roving order: shown avatars first, then the pill (when it is a button)\n  const focusableCount = shownItems.length + (pillIsInteractive ? 1 : 0)\n  const pillIndex = shownItems.length\n  const roving = Math.min(rovingIndex, Math.max(0, focusableCount - 1))\n\n  const fanned = hovered || focusWithin\n  const { px, text } = SIZES[size]\n  const layoutTransition = shouldReduceMotion ? INSTANT : SPRING_SOFT\n\n  const focusAt = useCallback(\n    (index: number, isPill: boolean) => {\n      setRovingIndex(index)\n      if (isPill) pillRef.current?.focus()\n      else avatarRefs.current[index]?.focus()\n    },\n    [],\n  )\n\n  const handleKeyDown = useCallback(\n    (event: React.KeyboardEvent) => {\n      if (event.key === \"Escape\") {\n        if (expanded) {\n          event.preventDefault()\n          setExpanded(false)\n          setRovingIndex(visibleMax)\n          pillRef.current?.focus()\n        }\n        return\n      }\n      if (focusableCount === 0) return\n      let next: number | null = null\n      switch (event.key) {\n        case \"ArrowRight\":\n        case \"ArrowDown\":\n          next = (roving + 1) % focusableCount\n          break\n        case \"ArrowLeft\":\n        case \"ArrowUp\":\n          next = (roving - 1 + focusableCount) % focusableCount\n          break\n        case \"Home\":\n          next = 0\n          break\n        case \"End\":\n          next = focusableCount - 1\n          break\n      }\n      if (next !== null) {\n        event.preventDefault()\n        focusAt(next, pillIsInteractive && next === pillIndex)\n      }\n    },\n    [expanded, visibleMax, focusableCount, roving, focusAt, pillIsInteractive, pillIndex],\n  )\n\n  return (\n    <div\n      role=\"group\"\n      aria-label={`${items.length} team members`}\n      className={cn(\"flex items-center\", className)}\n      onMouseEnter={() => setHovered(true)}\n      onMouseLeave={() => setHovered(false)}\n      onFocus={() => setFocusWithin(true)}\n      onBlur={(event) => {\n        if (!event.currentTarget.contains(event.relatedTarget as Node | null)) {\n          setFocusWithin(false)\n        }\n      }}\n      onKeyDown={handleKeyDown}\n    >\n      <AnimatePresence initial={false}>\n        {shownItems.map((item, index) => {\n          const isExtra = index >= visibleMax\n          const extraOrder = index - visibleMax\n          const isActive = activeIndex === index\n          return (\n            <motion.div\n              key={`${index}-${item.name}`}\n              layout\n              className=\"relative\"\n              style={{\n                marginLeft: index === 0 ? 0 : fanned ? FAN_GAP : OVERLAP_MARGIN,\n                zIndex: isActive ? LIFTED_Z_INDEX : undefined,\n              }}\n              transition={{ layout: layoutTransition }}\n              initial={\n                isExtra\n                  ? shouldReduceMotion\n                    ? { opacity: 0 }\n                    : { opacity: 0, scale: 0.4 }\n                  : false\n              }\n              animate={{\n                opacity: 1,\n                scale: 1,\n                transition: shouldReduceMotion\n                  ? INSTANT\n                  : { ...SPRING_SOFT, delay: isExtra ? extraOrder * STAGGER_S : 0 },\n              }}\n              exit={{\n                opacity: 0,\n                scale: shouldReduceMotion ? 1 : 0.4,\n                transition: shouldReduceMotion\n                  ? INSTANT\n                  : {\n                      duration: 0.18,\n                      ease: REVEAL_EASE,\n                      // Reverse stagger: last revealed avatar leaves first\n                      delay: isExtra\n                        ? (overflowCount - 1 - extraOrder) * STAGGER_S\n                        : 0,\n                    },\n              }}\n            >\n              <motion.button\n                ref={(node) => {\n                  avatarRefs.current[index] = node\n                }}\n                type=\"button\"\n                aria-label={item.name}\n                tabIndex={roving === index ? 0 : -1}\n                onClick={() => onAvatarClick?.(item, index)}\n                onMouseEnter={() => setActiveIndex(index)}\n                onMouseLeave={() =>\n                  setActiveIndex((current) => (current === index ? null : current))\n                }\n                onFocus={() => {\n                  setActiveIndex(index)\n                  setRovingIndex(index)\n                }}\n                onBlur={() =>\n                  setActiveIndex((current) => (current === index ? null : current))\n                }\n                initial={false}\n                animate={isActive ? { y: LIFT_Y, scale: LIFT_SCALE } : { y: 0, scale: 1 }}\n                transition={shouldReduceMotion ? INSTANT : SPRING_SNAPPY}\n                className={cn(\n                  \"flex select-none items-center justify-center overflow-hidden rounded-full bg-neutral-100 font-medium text-neutral-600 ring-2 ring-white dark:bg-neutral-800 dark:text-neutral-300 dark:ring-neutral-900\",\n                  \"focus-visible:outline-hidden focus-visible:ring-neutral-950 dark:focus-visible:ring-neutral-300\",\n                  text,\n                )}\n                style={{ width: px, height: px }}\n              >\n                {item.src ? (\n                  // eslint-disable-next-line @next/next/no-img-element\n                  <img\n                    src={item.src}\n                    alt={item.name}\n                    draggable={false}\n                    className=\"h-full w-full rounded-full object-cover\"\n                  />\n                ) : (\n                  <span aria-hidden=\"true\">{getInitials(item.name)}</span>\n                )}\n              </motion.button>\n\n              <AnimatePresence>\n                {isActive && (\n                  <motion.span\n                    aria-hidden=\"true\"\n                    className={cn(\n                      \"pointer-events-none absolute bottom-full left-1/2 mb-2 whitespace-nowrap rounded-full bg-neutral-900 px-2.5 py-1 text-xs font-medium text-white dark:bg-white dark:text-neutral-900\",\n                      TOOLTIP_SHADOW,\n                    )}\n                    style={{ transformOrigin: \"bottom center\" }}\n                    initial={\n                      shouldReduceMotion\n                        ? { opacity: 0, x: \"-50%\" }\n                        : {\n                            opacity: 0,\n                            x: \"-50%\",\n                            y: TOOLTIP_SHIFT_Y,\n                            scale: TOOLTIP_INITIAL_SCALE,\n                          }\n                    }\n                    animate={\n                      shouldReduceMotion\n                        ? { opacity: 1, x: \"-50%\" }\n                        : { opacity: 1, x: \"-50%\", y: 0, scale: 1 }\n                    }\n                    exit={\n                      shouldReduceMotion\n                        ? { opacity: 0, x: \"-50%\" }\n                        : {\n                            opacity: 0,\n                            x: \"-50%\",\n                            y: TOOLTIP_SHIFT_Y,\n                            scale: TOOLTIP_INITIAL_SCALE,\n                          }\n                    }\n                    transition={\n                      shouldReduceMotion\n                        ? { duration: 0.15, ease: REVEAL_EASE }\n                        : SPRING_SNAPPY\n                    }\n                  >\n                    {item.name}\n                  </motion.span>\n                )}\n              </AnimatePresence>\n            </motion.div>\n          )\n        })}\n      </AnimatePresence>\n\n      {hasOverflow &&\n        (pillIsInteractive ? (\n          <motion.button\n            ref={pillRef}\n            type=\"button\"\n            layout\n            aria-expanded={expanded}\n            aria-label={\n              expanded ? \"Show fewer\" : `Show ${overflowCount} more`\n            }\n            tabIndex={roving === pillIndex ? 0 : -1}\n            onClick={() => setExpanded((current) => !current)}\n            onFocus={() => setRovingIndex(pillIndex)}\n            transition={{ layout: layoutTransition }}\n            className={cn(\n              \"relative flex select-none items-center justify-center rounded-full bg-neutral-100 font-medium text-neutral-600 ring-2 ring-white dark:bg-neutral-800 dark:text-neutral-300 dark:ring-neutral-900\",\n              \"focus-visible:outline-hidden focus-visible:ring-neutral-950 dark:focus-visible:ring-neutral-300\",\n              text,\n            )}\n            style={{\n              width: px,\n              height: px,\n              marginLeft: fanned ? FAN_GAP : OVERLAP_MARGIN,\n            }}\n          >\n            <AnimatePresence initial={false}>\n              <motion.span\n                key={expanded ? \"collapse\" : \"count\"}\n                aria-hidden=\"true\"\n                className=\"absolute inset-0 flex items-center justify-center\"\n                initial={\n                  shouldReduceMotion ? { opacity: 0 } : { opacity: 0, scale: 0.6 }\n                }\n                animate={{ opacity: 1, scale: 1 }}\n                exit={\n                  shouldReduceMotion ? { opacity: 0 } : { opacity: 0, scale: 0.6 }\n                }\n                transition={\n                  shouldReduceMotion\n                    ? { duration: 0.15, ease: REVEAL_EASE }\n                    : SPRING_SNAPPY\n                }\n              >\n                {expanded ? \"−\" : `+${overflowCount}`}\n              </motion.span>\n            </AnimatePresence>\n          </motion.button>\n        ) : (\n          <motion.span\n            layout\n            transition={{ layout: layoutTransition }}\n            className={cn(\n              \"flex select-none items-center justify-center rounded-full bg-neutral-100 font-medium text-neutral-600 ring-2 ring-white dark:bg-neutral-800 dark:text-neutral-300 dark:ring-neutral-900\",\n              text,\n            )}\n            style={{\n              width: px,\n              height: px,\n              marginLeft: fanned ? FAN_GAP : OVERLAP_MARGIN,\n            }}\n          >\n            +{overflowCount}\n          </motion.span>\n        ))}\n    </div>\n  )\n}\n",
      "type": "registry:component",
      "target": "components/spectrumui/avatar-stack.tsx"
    }
  ],
  "type": "registry:component"
}
