{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"avatar-stack","type":"registry:ui","title":"Avatar Stack","description":"Overlapping avatars that fan out with names on hover, plus a bubble for the rest.","dependencies":["motion"],"registryDependencies":["https://ui.mekaigames.com/r/theme.json"],"files":[{"path":"components/ui/avatar-stack.tsx","type":"registry:ui","content":"\"use client\";\n\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { useState } from \"react\";\n\nexport type AvatarPerson = { name: string; color?: string };\n\ntype AvatarStackProps = {\n  people: AvatarPerson[];\n  /** Avatars shown before the \"+N\" bubble. */\n  max?: number;\n  /** Avatar diameter in pixels. */\n  size?: number;\n  /** Ring between overlapping avatars. Match it to the surface behind the stack. */\n  ringColor?: string;\n  className?: string;\n};\n\nconst PALETTE = [\"#ff784f\", \"#ffc24a\", \"#ffa585\", \"#9ad7b5\", \"#a9b8ff\", \"#e7b6ff\", \"#f4ede3\"];\n\nfunction colorFor(name: string) {\n  let hash = 0;\n  for (const ch of name) hash = (hash * 31 + ch.charCodeAt(0)) >>> 0;\n  return PALETTE[hash % PALETTE.length];\n}\n\nfunction initials(name: string) {\n  const parts = name.trim().split(/\\s+/);\n  const first = parts[0]?.[0] ?? \"\";\n  const last = parts.length > 1 ? parts[parts.length - 1][0] : \"\";\n  return (first + last).toUpperCase();\n}\n\n/** Overlapping avatars that fan out with names on hover or focus. */\nexport function AvatarStack({ people, max = 4, size = 40, ringColor = \"#070606\", className = \"\" }: AvatarStackProps) {\n  const reduce = useReducedMotion();\n  const [open, setOpen] = useState(false);\n  const shown = people.slice(0, Math.max(1, max));\n  const extra = people.length - shown.length;\n  const slots = shown.length + (extra > 0 ? 1 : 0);\n  const step = open ? size + 12 : size * 0.68;\n  const width = size + step * Math.max(0, slots - 1);\n  const spring = reduce ? { duration: 0 } : ({ type: \"spring\", stiffness: 420, damping: 30 } as const);\n  const fontSize = Math.round(size * 0.36);\n  const names = people.map((p) => p.name).join(\", \");\n\n  const face = { width: size, height: size, fontSize, boxShadow: `0 0 0 3px ${ringColor}` };\n\n  return (\n    <motion.div\n      role=\"group\"\n      aria-label={`${people.length} ${people.length === 1 ? \"person\" : \"people\"}: ${names}`}\n      tabIndex={0}\n      onPointerEnter={() => setOpen(true)}\n      onPointerLeave={() => setOpen(false)}\n      onFocus={() => setOpen(true)}\n      onBlur={() => setOpen(false)}\n      initial={false}\n      animate={{ width }}\n      transition={spring}\n      className={`relative shrink-0 rounded-full ${className}`}\n      style={{ height: size }}\n    >\n      {shown.map((person, i) => (\n        <motion.div\n          key={`${person.name}-${i}`}\n          aria-hidden=\"true\"\n          className=\"absolute left-0 top-0\"\n          initial={false}\n          animate={{ x: i * step }}\n          transition={spring}\n          style={{ zIndex: slots - i }}\n        >\n          <motion.span\n            title={person.name}\n            whileHover={reduce ? undefined : { y: -4, scale: 1.06 }}\n            transition={spring}\n            className=\"grid place-items-center rounded-full font-display font-extrabold text-ink-950\"\n            style={{ ...face, backgroundColor: person.color ?? colorFor(person.name) }}\n          >\n            {initials(person.name)}\n          </motion.span>\n          <AnimatePresence>\n            {open ? (\n              <motion.span\n                initial={{ opacity: 0, y: reduce ? 0 : -4 }}\n                animate={{ opacity: 1, y: 0 }}\n                exit={{ opacity: 0, transition: { duration: 0.1 } }}\n                transition={{ duration: reduce ? 0 : 0.2, delay: reduce ? 0 : i * 0.03 }}\n                className=\"absolute left-1/2 top-full mt-2 -translate-x-1/2 truncate text-center text-[11px] font-semibold text-bone-muted\"\n                style={{ maxWidth: size + 10 }}\n              >\n                {person.name.split(\" \")[0]}\n              </motion.span>\n            ) : null}\n          </AnimatePresence>\n        </motion.div>\n      ))}\n\n      {extra > 0 ? (\n        <motion.div\n          aria-hidden=\"true\"\n          className=\"absolute left-0 top-0\"\n          initial={false}\n          animate={{ x: shown.length * step }}\n          transition={spring}\n          style={{ zIndex: 0 }}\n        >\n          <span\n            title={people\n              .slice(shown.length)\n              .map((p) => p.name)\n              .join(\", \")}\n            className=\"grid place-items-center rounded-full bg-ink-700 font-semibold text-bone-muted\"\n            style={face}\n          >\n            +{extra}\n          </span>\n          <AnimatePresence>\n            {open ? (\n              <motion.span\n                initial={{ opacity: 0 }}\n                animate={{ opacity: 1 }}\n                exit={{ opacity: 0, transition: { duration: 0.1 } }}\n                transition={{ duration: reduce ? 0 : 0.2, delay: reduce ? 0 : shown.length * 0.03 }}\n                className=\"absolute left-1/2 top-full mt-2 -translate-x-1/2 whitespace-nowrap text-[11px] font-semibold text-bone-dim\"\n              >\n                more\n              </motion.span>\n            ) : null}\n          </AnimatePresence>\n        </motion.div>\n      ) : null}\n    </motion.div>\n  );\n}\n"}]}