{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"presence-avatars","type":"registry:ui","title":"Presence Avatars","description":"Join and leave as a layout change.","dependencies":["motion"],"categories":["notification"],"docs":"https://www.interior.dev/docs/presence-avatars","files":[{"path":"registry/interior/presence-avatars.tsx","type":"registry:ui","target":"components/interior/presence-avatars.tsx","content":"\"use client\";\n\nimport { useEffect, useMemo, useRef, useState } from \"react\";\nimport {\n  AnimatePresence,\n  motion,\n  useIsomorphicLayoutEffect,\n  useReducedMotion,\n} from \"motion/react\";\n\nconst SLOT = { type: \"spring\", stiffness: 520, damping: 34, mass: 0.45 } as const;\nconst FADE = { duration: 0.24, ease: [0.23, 1, 0.32, 1] } as const;\nconst INSTANT = { duration: 0 } as const;\n\nexport type PresencePerson = {\n  id: string;\n  name: string;\n  src?: string;\n};\n\nexport type UsePresenceOptions = {\n  people: PresencePerson[];\n  max?: number;\n  announceAfter?: number;\n};\n\nexport type UsePresenceResult = {\n  ordered: PresencePerson[];\n  visible: PresencePerson[];\n  hidden: PresencePerson[];\n  overflow: number;\n  total: number;\n  summary: string;\n  announcement: string;\n};\n\nfunction initials(name: string): string {\n  const words = name.trim().split(/\\s+/).filter(Boolean);\n  if (words.length === 0) return \"?\";\n  const first = Array.from(words[0])[0] ?? \"\";\n  const last = words.length > 1 ? (Array.from(words[words.length - 1])[0] ?? \"\") : \"\";\n  return (first + last).toUpperCase();\n}\n\nfunction describe(names: string[]): string {\n  if (names.length === 0) return \"Nobody here\";\n  if (names.length === 1) return `${names[0]} is here`;\n  if (names.length === 2) return `${names[0]} and ${names[1]} are here`;\n  const rest = names.length - 2;\n  return `${names[0]}, ${names[1]} and ${rest} ${rest === 1 ? \"other\" : \"others\"} are here`;\n}\n\nexport function usePresence({\n  people,\n  max = 5,\n  announceAfter = 900,\n}: UsePresenceOptions): UsePresenceResult {\n  const seen = useRef(new Map<string, number>());\n  const next = useRef(0);\n\n  const ordered = useMemo(() => {\n    const order = seen.current;\n    for (const person of people) {\n      if (!order.has(person.id)) {\n        order.set(person.id, next.current);\n        next.current += 1;\n      }\n    }\n    return people\n      .slice()\n      .toSorted((a, b) => (order.get(a.id) ?? 0) - (order.get(b.id) ?? 0));\n  }, [people]);\n\n  const slots = Math.max(1, max);\n  const visible = ordered.slice(0, slots);\n  const hidden = ordered.slice(slots);\n  const summary = describe(ordered.map((person) => person.name));\n\n  const [announcement, setAnnouncement] = useState(summary);\n\n  useEffect(() => {\n    const timer = setTimeout(() => setAnnouncement(summary), announceAfter);\n    return () => clearTimeout(timer);\n  }, [summary, announceAfter]);\n\n  return {\n    ordered,\n    visible,\n    hidden,\n    overflow: hidden.length,\n    total: ordered.length,\n    summary,\n    announcement,\n  };\n}\n\nconst TILE =\n  \"absolute left-0 top-0 select-none rounded-[10px] bg-stone-200 p-[3px] dark:bg-stone-700\";\nconst WELL =\n  \"relative grid size-full place-items-center overflow-hidden rounded-[7px] bg-stone-100 font-medium leading-none text-stone-500 dark:bg-white/10 dark:text-stone-300\";\n\ntype TileProps = {\n  person: PresencePerson;\n  index: number;\n  step: number;\n  size: number;\n  zIndex: number;\n  reduced: boolean;\n};\n\ntype FaceStatus = \"loading\" | \"ready\" | \"error\";\n\nfunction useFace(src?: string) {\n  const ref = useRef<HTMLImageElement>(null);\n  const [state, setState] = useState<{ status: FaceStatus; instant: boolean }>({\n    status: \"loading\",\n    instant: false,\n  });\n\n  useIsomorphicLayoutEffect(() => {\n    const img = ref.current;\n\n    const set = (status: FaceStatus, instant: boolean) =>\n      setState((prev) =>\n        prev.status === status && prev.instant === instant\n          ? prev\n          : { status, instant },\n      );\n\n    if (!img || !src) {\n      set(\"loading\", false);\n      return;\n    }\n\n    const cached = img.complete && img.naturalWidth > 0;\n    if (img.complete) {\n      set(cached ? \"ready\" : \"error\", cached);\n      return;\n    }\n\n    set(\"loading\", false);\n\n    let alive = true;\n    const onLoad = () => {\n      if (alive) set(\"ready\", false);\n    };\n    const onError = () => {\n      if (alive) set(\"error\", false);\n    };\n\n    img.addEventListener(\"load\", onLoad);\n    img.addEventListener(\"error\", onError);\n\n    return () => {\n      alive = false;\n      img.removeEventListener(\"load\", onLoad);\n      img.removeEventListener(\"error\", onError);\n    };\n  }, [src]);\n\n  return { ref, status: state.status, instant: state.instant };\n}\n\nfunction PresenceTile({ person, index, step, size, zIndex, reduced }: TileProps) {\n  const { ref, status, instant } = useFace(person.src);\n\n  return (\n    <motion.span\n      aria-hidden\n      initial={{ opacity: 0, scale: 0.86, x: index * step }}\n      animate={{ opacity: 1, scale: 1, x: index * step }}\n      exit={{ opacity: 0, scale: 0.86 }}\n      transition={reduced ? INSTANT : SLOT}\n      style={{ width: size, height: size, zIndex, fontSize: Math.round(size * 0.34) }}\n      className={TILE}\n    >\n      <span className={WELL}>\n        {initials(person.name)}\n\n        {person.src ? (\n          <motion.img\n            ref={ref}\n            src={person.src}\n            alt=\"\"\n            width={size}\n            height={size}\n            decoding=\"async\"\n            initial={false}\n            animate={{ opacity: status === \"ready\" ? 1 : 0 }}\n            transition={reduced || instant ? INSTANT : FADE}\n            className=\"absolute inset-0 size-full object-cover\"\n          />\n        ) : null}\n      </span>\n    </motion.span>\n  );\n}\n\nexport type PresenceAvatarsProps = {\n  people: PresencePerson[];\n  max?: number;\n  size?: number;\n  overlap?: number;\n  label?: string;\n  announceAfter?: number;\n  onOverflowSelect?: (hidden: PresencePerson[]) => void;\n  className?: string;\n};\n\nexport function PresenceAvatars({\n  people,\n  max = 5,\n  size = 28,\n  overlap = 9,\n  label = \"People here\",\n  announceAfter,\n  onOverflowSelect,\n  className = \"\",\n}: PresenceAvatarsProps) {\n  const reduced = useReducedMotion();\n  const { ordered, visible, hidden, overflow, announcement } = usePresence({\n    people,\n    max,\n    announceAfter,\n  });\n\n  const slots = Math.max(1, max);\n  const step = size - overlap;\n  const chip = size + 8;\n\n  const rail =\n    visible.length === 0\n      ? 0\n      : overflow > 0\n        ? visible.length * step + chip\n        : (visible.length - 1) * step + size;\n\n  const chipCount = `+${Math.min(overflow, 99)}`;\n  const chipMotion = {\n    initial: { opacity: 0, scale: 0.86 },\n    animate: { opacity: 1, scale: 1, x: visible.length * step },\n    exit: { opacity: 0, scale: 0.86 },\n    transition: reduced ? INSTANT : SLOT,\n  };\n  const chipClass =\n    \"absolute left-0 top-0 grid place-items-center rounded-[9px] border border-stone-200 bg-white font-mono text-[10.5px] leading-none tabular-nums text-stone-500 outline-none ring-2 ring-white dark:border-white/[0.16] dark:bg-[#1D1D1A] dark:text-stone-400 dark:ring-stone-900\";\n\n  return (\n    <div role=\"group\" aria-label={label} className={`inline-flex items-center ${className}`}>\n      <motion.div\n        className=\"relative shrink-0\"\n        style={{ height: size }}\n        initial={false}\n        animate={{ width: rail }}\n        transition={reduced ? INSTANT : SLOT}\n      >\n        <AnimatePresence initial={false}>\n          {visible.map((person, i) => (\n            <PresenceTile\n              key={person.id}\n              person={person}\n              index={i}\n              step={step}\n              size={size}\n              zIndex={slots - i}\n              reduced={Boolean(reduced)}\n            />\n          ))}\n\n          {overflow > 0 &&\n            (onOverflowSelect ? (\n              <motion.button\n                key=\"overflow\"\n                type=\"button\"\n                onClick={() => onOverflowSelect(hidden)}\n                aria-label={`Show ${overflow} more`}\n                style={{ width: chip, height: size, zIndex: 0 }}\n                className={`${chipClass} focus-visible:border-[#4568FF] dark:focus-visible:border-[#93B0FF]`}\n                {...chipMotion}\n              >\n                <span aria-hidden>{chipCount}</span>\n              </motion.button>\n            ) : (\n              <motion.span\n                key=\"overflow\"\n                aria-hidden\n                style={{ width: chip, height: size, zIndex: 0 }}\n                className={chipClass}\n                {...chipMotion}\n              >\n                {chipCount}\n              </motion.span>\n            ))}\n        </AnimatePresence>\n      </motion.div>\n      <ul className=\"sr-only\">\n        {ordered.map((person) => (\n          <li key={person.id}>{person.name}</li>\n        ))}\n      </ul>\n      <span role=\"status\" aria-live=\"polite\" aria-atomic=\"true\" className=\"sr-only\">\n        {announcement}\n      </span>\n    </div>\n  );\n}\n"}]}