{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"avatar-stack","type":"registry:component","title":"Avatar stack","description":"Fans open when you reach for it, with a name above each face.","author":"Yash Bavadiya <https://xevrion.dev>","dependencies":["motion"],"registryDependencies":["utils"],"files":[{"path":"components/avatar-stack.tsx","type":"registry:component","content":"\"use client\";\n\nimport { useRef, useState } from \"react\";\nimport { motion, useSpring, useTransform, type MotionValue } from \"motion/react\";\nimport { useReducedMotion } from \"@/hooks/use-reduced-motion\";\nimport Image from \"next/image\";\nimport { cn } from \"@/lib/utils\";\n\nexport type Person = { name: string; avatar?: string };\n\nconst SIZE = 44;\n// Stacked, each avatar hides a third of the one before it.\nconst CLOSED_STEP = 30;\n// Fanned, a 6px gap: close enough to still read as one group.\nconst OPEN_STEP = 50;\n// A touch of bounce so the fan feels like it springs open, not slides.\nconst FAN = { visualDuration: 0.3, bounce: 0.15 };\n\n// Opaque tints, since overlapping translucent ones would show each other\n// through. Written out in full so Tailwind can see every class.\nconst TINTS = [\n  \"bg-[color-mix(in_oklab,var(--color-foreground)_8%,var(--color-background))]\",\n  \"bg-[color-mix(in_oklab,var(--color-foreground)_14%,var(--color-background))]\",\n  \"bg-[color-mix(in_oklab,var(--color-foreground)_20%,var(--color-background))]\",\n  \"bg-[color-mix(in_oklab,var(--color-foreground)_11%,var(--color-background))]\",\n  \"bg-[color-mix(in_oklab,var(--color-foreground)_17%,var(--color-background))]\",\n];\n\nfunction initials(name: string) {\n  return name\n    .split(\" \")\n    .map((part) => part[0])\n    .join(\"\")\n    .slice(0, 2)\n    .toUpperCase();\n}\n\nfunction listNames(names: string[]) {\n  if (names.length < 2) return names.join(\"\");\n  return `${names.slice(0, -1).join(\", \")} and ${names.at(-1)}`;\n}\n\nexport function AvatarStack({\n  people,\n  max = 5,\n  label = \"Members\",\n  className,\n}: {\n  people: Person[];\n  max?: number;\n  label?: string;\n  className?: string;\n}) {\n  const reduceMotion = useReducedMotion();\n  const shown = people.slice(0, max);\n  const hidden = people.slice(max);\n  const count = shown.length + (hidden.length > 0 ? 1 : 0);\n  const mid = (count - 1) / 2;\n\n  const step = useSpring(CLOSED_STEP, FAN);\n  // Refs, not state: fanning is driven by one motion value, so hover and\n  // focus never re-render the stack.\n  const hovered = useRef(false);\n  const focused = useRef(false);\n  const buttons = useRef<(HTMLButtonElement | null)[]>([]);\n  // Roving tabindex: the whole stack is one tab stop, arrows move within it.\n  const [active, setActive] = useState(0);\n\n  const update = () => {\n    const target = hovered.current || focused.current ? OPEN_STEP : CLOSED_STEP;\n    if (reduceMotion) step.jump(target);\n    else step.set(target);\n  };\n\n  const move = (index: number) => {\n    setActive(index);\n    buttons.current[index]?.focus();\n  };\n\n  const items = [\n    ...shown.map((person, i) => ({\n      key: person.name,\n      text: initials(person.name),\n      avatar: person.avatar,\n      name: person.name,\n      tooltip: person.name,\n      tint: TINTS[i % TINTS.length],\n    })),\n    ...(hidden.length > 0\n      ? [\n          {\n            key: \"overflow\",\n            text: `+${hidden.length}`,\n            avatar: undefined,\n            name: `${hidden.length} more: ${listNames(hidden.map((p) => p.name))}`,\n            tooltip: listNames(hidden.map((p) => p.name)),\n            tint: \"bg-muted text-muted-foreground\",\n          },\n        ]\n      : []),\n  ];\n\n  return (\n    <div\n      role=\"toolbar\"\n      aria-label={label}\n      // Sized for the fanned stack up front, and every avatar is placed from\n      // the center, so fanning grows both ways and nothing around it moves.\n      style={{ width: (count - 1) * OPEN_STEP + SIZE, height: SIZE }}\n      className={cn(\"relative\", className)}\n      onPointerEnter={(e) => {\n        if (e.pointerType === \"touch\") return;\n        hovered.current = true;\n        update();\n      }}\n      onPointerLeave={(e) => {\n        if (e.pointerType === \"touch\") return;\n        hovered.current = false;\n        update();\n      }}\n      onFocus={(e) => {\n        // A mouse click also focuses; only keyboard focus should hold it open.\n        if (!e.target.matches(\":focus-visible\")) return;\n        focused.current = true;\n        update();\n      }}\n      onBlur={(e) => {\n        if (e.currentTarget.contains(e.relatedTarget)) return;\n        focused.current = false;\n        update();\n      }}\n      onKeyDown={(e) => {\n        const last = count - 1;\n        const next =\n          e.key === \"ArrowRight\"\n            ? Math.min(active + 1, last)\n            : e.key === \"ArrowLeft\"\n              ? Math.max(active - 1, 0)\n              : e.key === \"Home\"\n                ? 0\n                : e.key === \"End\"\n                  ? last\n                  : null;\n        if (next === null) return;\n        e.preventDefault();\n        move(next);\n      }}\n    >\n      {items.map((item, i) => (\n        <Slot key={item.key} step={step} offset={i - mid}>\n          <button\n            ref={(el) => {\n              buttons.current[i] = el;\n            }}\n            type=\"button\"\n            tabIndex={i === active ? 0 : -1}\n            aria-label={item.name}\n            onFocus={() => setActive(i)}\n            className={cn(\n              \"group relative flex size-full touch-manipulation items-center justify-center rounded-full text-sm font-medium text-foreground ring-2 ring-background outline-hidden select-none\",\n              \"transition-[translate,scale] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]\",\n              \"motion-safe:hover:-translate-y-0.5 motion-safe:hover:scale-105 motion-safe:focus-visible:-translate-y-0.5 motion-safe:focus-visible:scale-105\",\n              \"focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-offset-2 focus-visible:outline-foreground active:scale-[0.96]\",\n              // Line-drawn faces are black ink, so they sit on a light circle in\n              // both themes, like a printed photo; initials keep the theme tints.\n              item.avatar ? \"bg-[oklch(0.97_0_0)]\" : item.tint,\n            )}\n          >\n            {item.avatar ? (\n              <Image\n                src={item.avatar}\n                alt=\"\"\n                width={SIZE}\n                height={SIZE}\n                unoptimized\n                draggable={false}\n                className=\"size-full rounded-full\"\n              />\n            ) : (\n              <span aria-hidden>{item.text}</span>\n            )}\n            {/* Enters in 150ms, leaves in 100ms: the exit never holds the eye. */}\n            <span\n              aria-hidden\n              className={cn(\n                \"pointer-events-none absolute bottom-full left-1/2 mb-2 -translate-x-1/2 origin-bottom rounded-full bg-foreground px-2.5 py-1 text-xs font-medium whitespace-nowrap text-background\",\n                \"invisible translate-y-0.5 scale-[0.97] opacity-0 transition-[opacity,scale,translate,visibility] duration-100 ease-[cubic-bezier(0.23,1,0.32,1)]\",\n                \"group-hover:visible group-hover:translate-y-0 group-hover:scale-100 group-hover:opacity-100 group-hover:duration-150\",\n                \"group-focus-visible:visible group-focus-visible:translate-y-0 group-focus-visible:scale-100 group-focus-visible:opacity-100 group-focus-visible:duration-150\",\n                \"motion-reduce:translate-y-0 motion-reduce:scale-100\",\n              )}\n            >\n              {item.tooltip}\n            </span>\n          </button>\n        </Slot>\n      ))}\n    </div>\n  );\n}\n\nfunction Slot({\n  step,\n  offset,\n  children,\n}: {\n  step: MotionValue<number>;\n  offset: number;\n  children: React.ReactNode;\n}) {\n  const x = useTransform(step, (s) => offset * s);\n  return (\n    <motion.div\n      style={{ x, width: SIZE, marginLeft: -SIZE / 2 }}\n      // Lifts whichever avatar is in use above its neighbours and their\n      // tooltips.\n      className=\"absolute top-0 left-1/2 h-full hover:z-10 focus-within:z-10\"\n    >\n      {children}\n    </motion.div>\n  );\n}\n\n// Faces are \"Notionists\" by Zoish, CC0 1.0, stored in public/avatars.\nconst TEAM: Person[] = [\n  { name: \"Amara Okafor\", avatar: \"/avatars/ava.svg\" },\n  { name: \"Jonas Weber\", avatar: \"/avatars/ben.svg\" },\n  { name: \"Mei Tanaka\", avatar: \"/avatars/cara.svg\" },\n  { name: \"Rafael Costa\", avatar: \"/avatars/dev.svg\" },\n  { name: \"Sara Lindqvist\", avatar: \"/avatars/fay.svg\" },\n  { name: \"Dev Patel\" },\n  { name: \"Ines Moreau\" },\n  { name: \"Tom Hughes\" },\n];\n\nexport default function AvatarStackDemo() {\n  return <AvatarStack people={TEAM} label=\"Project members\" />;\n}\n"},{"path":"hooks/use-reduced-motion.ts","type":"registry:hook","target":"hooks/use-reduced-motion.ts","content":"\"use client\";\n\nimport { useSyncExternalStore } from \"react\";\n\n// Motion's useReducedMotion reads the media query on the very first client\n// render, but the server can't know it, so every component that renders\n// differently under reduced motion broke hydration for those users. This\n// reports false during hydration, matching the server HTML, then the real\n// preference straight after, and follows it if it changes.\nconst query = \"(prefers-reduced-motion: reduce)\";\n\nfunction subscribe(onChange: () => void) {\n  const media = window.matchMedia(query);\n  media.addEventListener(\"change\", onChange);\n  return () => media.removeEventListener(\"change\", onChange);\n}\n\nexport function useReducedMotion() {\n  return useSyncExternalStore(\n    subscribe,\n    () => window.matchMedia(query).matches,\n    () => false,\n  );\n}\n"}],"categories":["cards"],"docs":"From ui lab: https://lab.xevrion.dev/lab/avatar-stack"}