{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "presence-stack",
  "type": "registry:ui",
  "title": "Presence Stack",
  "description": "Live faces that slide in, ping once, fade out and roll the overflow count.",
  "categories": [
    "collaboration"
  ],
  "dependencies": [
    "@base-ui/react",
    "@number-flow/react",
    "motion"
  ],
  "registryDependencies": [
    "https://www.stealth.pm/r/tokens.json",
    "https://www.stealth.pm/r/cn.json",
    "https://www.stealth.pm/r/motion.json"
  ],
  "files": [
    {
      "path": "components/ui/presence-stack.tsx",
      "content": "\"use client\";\nimport { Avatar } from \"@base-ui/react/avatar\";\nimport { Popover } from \"@base-ui/react/popover\";\nimport { Tooltip } from \"@base-ui/react/tooltip\";\nimport NumberFlow from \"@number-flow/react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { useState } from \"react\";\nimport { cn } from \"@/lib/cn\";\nimport { ease, spring } from \"@/lib/motion\";\n\nexport type PresencePerson = {\n  /** Stable id from your presence channel. Keys the arrival and exit animations. */\n  id: string;\n  name: string;\n  src?: string;\n  /** A short second line: \"Editing Pricing\", \"Viewing\", \"Idle 4m\". */\n  meta?: string;\n  /** Connected but not active. Their face dims; they stay in the stack. */\n  idle?: boolean;\n  /** The viewer themselves. Labeled \"(you)\" and never announced. */\n  self?: boolean;\n};\n\ntype Size = \"sm\" | \"md\" | \"lg\";\nconst sizes: Record<Size, number> = { sm: 24, md: 28, lg: 32 };\n\n/* -------------------------------------------------------------------------------------------------\n * Faces\n * -----------------------------------------------------------------------------------------------*/\n\nfunction initials(name: string) {\n  const words = name.replace(/\\(.*?\\)/g, \" \").split(\"@\")[0].trim().split(/[\\s._-]+/).filter(Boolean);\n  const first = (w: string) => Array.from(w)[0] ?? \"\";\n  if (!words.length) return \"\";\n  if (words.length === 1) return first(words[0]).toUpperCase();\n  return (first(words[0]) + first(words[words.length - 1])).toUpperCase();\n}\n\n// Steps of the foreground, not hues: people are told apart by initials and names, and color\n// stays reserved for meaning. The same id always lands on the same step.\nconst tones = [\"bg-fg/[0.07] text-fg-2\", \"bg-fg/[0.1] text-fg-2\", \"bg-fg/[0.13] text-fg\", \"bg-fg/[0.17] text-fg\", \"bg-fg/[0.22] text-fg\"];\nfunction tone(key: string) {\n  let h = 0x811c9dc5;\n  for (let i = 0; i < key.length; i++) h = Math.imul(h ^ key.charCodeAt(i), 0x01000193);\n  return tones[(h >>> 0) % tones.length];\n}\n\n// `shift` moves initials right when a neighbor covers the left edge, so they center in what shows.\nfunction Face({ person, size, mask, shift = 0, active }: { person: PresencePerson; size: number; mask?: string; shift?: number; active?: boolean }) {\n  return (\n    <Avatar.Root\n      data-idle={person.idle || undefined}\n      className=\"relative block shrink-0 overflow-hidden rounded-full transition-[opacity,filter] duration-300 ease-out-quart data-idle:opacity-45 data-idle:grayscale\"\n      style={{ width: size, height: size, maskImage: mask, WebkitMaskImage: mask }}\n    >\n      <Avatar.Fallback\n        className={cn(\"absolute inset-0 grid place-items-center font-medium leading-none tracking-[0.01em]\", tone(person.id))}\n        style={{ fontSize: Math.round(size * 0.38 * 2) / 2, paddingLeft: shift }}\n      >\n        {initials(person.name)}\n      </Avatar.Fallback>\n      {person.src && (\n        <Avatar.Image\n          src={person.src}\n          alt=\"\"\n          className=\"absolute inset-0 size-full object-cover transition-opacity duration-200 ease-out-quart data-starting-style:opacity-0\"\n        />\n      )}\n      {/* The follow ring draws inward from the edge: a foreground line, then a gap, then the face. */}\n      <span\n        aria-hidden\n        data-active={active || undefined}\n        className={cn(\n          \"pointer-events-none absolute inset-0 rounded-full transition-[box-shadow] duration-240 ease-out-expo\",\n          \"shadow-[inset_0_0_0_0_var(--fg),inset_0_0_0_0_var(--frame)]\",\n          \"data-active:shadow-[inset_0_0_0_1.5px_var(--fg),inset_0_0_0_3px_var(--frame)]\",\n        )}\n      />\n    </Avatar.Root>\n  );\n}\n\n/* -------------------------------------------------------------------------------------------------\n * Words\n * -----------------------------------------------------------------------------------------------*/\n\nconst displayName = (p: PresencePerson) => (p.self ? `${p.name} (you)` : p.name);\nconst list = new Intl.ListFormat(\"en\", { type: \"conjunction\" });\n\nfunction summarize(label: string, people: PresencePerson[]) {\n  const names = people.map(displayName);\n  if (names.length <= 3) return `${label}: ${list.format(names)}`;\n  return `${label}: ${names.slice(0, 2).join(\", \")} and ${names.length - 2} others`;\n}\n\nfunction shortNames(people: PresencePerson[]) {\n  const first = people.slice(0, 2).map((p) => (p.self ? \"You\" : p.name.split(/\\s+/)[0]));\n  const rest = people.length - first.length;\n  return rest > 0 ? `${first.join(\", \")} and ${rest} more` : list.format(first);\n}\n\n// \"Maya Okafor joined\", \"Jon Park and Lena Fischer left\", \"3 people joined\".\nfunction change(people: PresencePerson[], verb: \"joined\" | \"left\") {\n  const others = people.filter((p) => !p.self);\n  if (!others.length) return \"\";\n  if (others.length > 2) return `${others.length} people ${verb}`;\n  return `${list.format(others.map((p) => p.name))} ${verb}`;\n}\n\n/* -------------------------------------------------------------------------------------------------\n * PresenceStack\n * -----------------------------------------------------------------------------------------------*/\n\ntype Tip = { title: string; meta?: string; hint?: string };\n\nexport type PresenceStackProps = Omit<React.ComponentProps<\"div\">, \"children\"> & {\n  /** Everyone connected right now, in the order to draw them. */\n  people: PresencePerson[];\n  /** Most circles drawn, the +N chip included. One extra person is drawn rather than a \"+1\". */\n  max?: number;\n  size?: Size;\n  /** What the stack is. Names the group and titles the full list. */\n  label?: string;\n  /** Makes each face a button: follow them, jump to their cursor. */\n  onPersonClick?: (person: PresencePerson) => void;\n  /** The person being followed. Their face gets a ring that draws in. */\n  activeId?: string | null;\n  /** Tooltip hint on faces when they are buttons. */\n  actionHint?: string;\n  /** Announce arrivals and departures to screen readers. */\n  announce?: boolean;\n  /** Placeholder faces while the presence channel connects. */\n  loading?: boolean;\n  /** Rendered when nobody is here. Nothing by default. */\n  empty?: React.ReactNode;\n  /** Where the tooltip and list portal. Defaults to document.body. */\n  container?: HTMLElement | React.RefObject<HTMLElement | null> | null;\n};\n\n/**\n * Who is here right now. Arrivals slide in and ping once, leavers fade while the rest close\n * ranks, the overflow count rolls, and one tooltip glides from face to face.\n */\nexport function PresenceStack({\n  people,\n  max = 4,\n  size = \"md\",\n  label = \"Viewing now\",\n  onPersonClick,\n  activeId = null,\n  actionHint = \"Click to follow\",\n  announce = true,\n  loading = false,\n  empty = null,\n  container,\n  className,\n  onKeyDown,\n  ...rest\n}: PresenceStackProps) {\n  const reduce = useReducedMotion();\n  const [tip] = useState(() => Tooltip.createHandle<Tip>());\n  const [open, setOpen] = useState(false);\n  const [focusIndex, setFocusIndex] = useState(0);\n\n  // Diff the list as it changes, during render, so the announcement lands with the new faces.\n  // Only the latest arrivals ping: a face that slides out of the overflow later is not news.\n  const [prev, setPrev] = useState(people);\n  const [message, setMessage] = useState(\"\");\n  const [fresh, setFresh] = useState<Set<string>>(() => new Set());\n  if (prev !== people) {\n    const before = new Set(prev.map((p) => p.id));\n    const after = new Set(people.map((p) => p.id));\n    const arrivals = people.filter((p) => !before.has(p.id));\n    const joined = change(arrivals, \"joined\");\n    const left = change(prev.filter((p) => !after.has(p.id)), \"left\");\n    setPrev(people);\n    if (arrivals.length || left) setFresh(new Set(arrivals.map((p) => p.id)));\n    if (joined || left) setMessage([joined, left].filter(Boolean).join(\". \"));\n  }\n\n  const s = sizes[size];\n  const overlap = Math.round(s * 0.28);\n  const gap = s >= 28 ? 2 : 1.5;\n  const cap = Math.max(2, max);\n  const fits = people.length <= cap;\n  const shown = fits ? people : people.slice(0, cap - 1);\n  const hidden = fits ? [] : people.slice(cap - 1);\n  const interactive = !!onPersonClick;\n  const count = shown.length + (hidden.length ? 1 : 0);\n  const current = Math.min(focusIndex, Math.max(0, count - 1));\n\n  // Each circle after the first has the one before it punched out, plus a hairline of air, so the\n  // stack reads on any surface: a card, a hover wash, a photo. The opaque stop only lends alpha.\n  const cut = `radial-gradient(circle at ${overlap - s / 2}px 50%, transparent ${s / 2 + gap}px, var(--fg) ${s / 2 + gap + 0.5}px)`;\n  const slot = (i: number): React.CSSProperties => ({ zIndex: 20 - i, marginLeft: i === 0 ? 0 : -overlap });\n\n  const enter = reduce ? { opacity: 0 } : { opacity: 0, x: 8, scale: 0.7 };\n  const leave = reduce ? { opacity: 0, transition: { duration: 0.12 } } : { opacity: 0, scale: 0.7, filter: \"blur(2px)\", transition: { duration: 0.16, ease: ease.out } };\n  const transition = reduce ? { duration: 0.15 } : { ...spring.pop, opacity: { duration: 0.18, ease: ease.out }, layout: spring.snappy };\n\n  // Roving focus: one tab stop for the whole stack, arrows move between faces.\n  const move = (e: React.KeyboardEvent<HTMLDivElement>) => {\n    onKeyDown?.(e);\n    if (!interactive || e.defaultPrevented) return;\n    const last = count - 1;\n    const next = e.key === \"ArrowRight\" ? Math.min(last, current + 1) : e.key === \"ArrowLeft\" ? Math.max(0, current - 1) : e.key === \"Home\" ? 0 : e.key === \"End\" ? last : -1;\n    if (next < 0) return;\n    e.preventDefault();\n    setFocusIndex(next);\n    e.currentTarget.querySelectorAll<HTMLElement>(\"[data-presence-item]\")[next]?.focus();\n  };\n\n  if (loading)\n    return (\n      <div role=\"img\" aria-busy aria-label={`Loading ${label.toLowerCase()}`} className={cn(\"inline-flex\", className)} {...rest}>\n        {[0, 1, 2].map((i) => (\n          <span\n            key={i}\n            className=\"block animate-pulse-soft rounded-full bg-hover motion-reduce:animate-none\"\n            style={{ ...slot(i), width: s, height: s, maskImage: i ? cut : undefined, WebkitMaskImage: i ? cut : undefined }}\n          />\n        ))}\n      </div>\n    );\n\n  const live = announce && (\n    <span role=\"status\" aria-live=\"polite\" className=\"sr-only\">\n      {message}\n    </span>\n  );\n\n  if (people.length === 0)\n    return (\n      <>\n        {empty}\n        {live}\n      </>\n    );\n\n  return (\n    <div\n      role={interactive ? \"toolbar\" : \"group\"}\n      aria-label={summarize(label, people)}\n      onKeyDown={move}\n      className={cn(\"relative isolate inline-flex items-center\", className)}\n      {...rest}\n    >\n      <AnimatePresence initial={false} mode=\"popLayout\">\n        {shown.map((p, i) => {\n          const arrived = fresh.has(p.id);\n          const active = p.id === activeId;\n          const face = <Face person={p} size={s} mask={i === 0 ? undefined : cut} shift={i === 0 ? 0 : Math.round(overlap / 2)} active={active} />;\n          const tipPayload: Tip = { title: displayName(p), meta: p.idle && !p.meta ? \"Idle\" : p.meta, hint: interactive && !p.self ? (active ? \"Following\" : actionHint) : undefined };\n          return (\n            <motion.span\n              key={p.id}\n              layout={!reduce}\n              initial={enter}\n              animate={{ opacity: 1, x: 0, scale: 1, filter: \"blur(0px)\" }}\n              exit={leave}\n              transition={transition}\n              className=\"relative flex rounded-full\"\n              style={slot(i)}\n            >\n              {/* A single ring that ripples out once, so a new face is noticed without a toast. */}\n              {arrived && !reduce && (\n                <motion.span\n                  aria-hidden\n                  className=\"pointer-events-none absolute inset-0 rounded-full border border-fg\"\n                  initial={{ scale: 1, opacity: 0.5 }}\n                  animate={{ scale: 1.6, opacity: 0 }}\n                  transition={{ duration: 0.9, ease: ease.out, delay: 0.12 }}\n                />\n              )}\n              <Tooltip.Trigger\n                handle={tip}\n                payload={tipPayload}\n                delay={350}\n                render={\n                  interactive ? (\n                    <button\n                      type=\"button\"\n                      data-presence-item=\"\"\n                      aria-label={`${displayName(p)}${p.meta ? `, ${p.meta}` : \"\"}`}\n                      aria-pressed={active}\n                      tabIndex={i === current ? 0 : -1}\n                      onFocus={() => setFocusIndex(i)}\n                      onClick={() => onPersonClick?.(p)}\n                      className={cn(\n                        \"relative flex rounded-full outline-none\",\n                        \"transition-[translate,scale] duration-150 ease-out-quart hover:-translate-y-0.5 active:scale-[0.92] active:duration-75\",\n                        \"focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3\",\n                        \"motion-reduce:hover:translate-y-0\",\n                      )}\n                    />\n                  ) : (\n                    <span className=\"flex rounded-full\" />\n                  )\n                }\n              >\n                {face}\n              </Tooltip.Trigger>\n            </motion.span>\n          );\n        })}\n\n        {hidden.length > 0 && (\n          <motion.span\n            key=\"overflow\"\n            layout={!reduce}\n            initial={enter}\n            animate={{ opacity: 1, x: 0, scale: 1, filter: \"blur(0px)\" }}\n            exit={leave}\n            transition={transition}\n            className=\"relative flex rounded-full\"\n            style={slot(shown.length)}\n          >\n            <Popover.Root open={open} onOpenChange={setOpen}>\n              <Popover.Trigger\n                data-presence-item=\"\"\n                aria-label={`${hidden.length} more: ${hidden.map(displayName).join(\", \")}. Show everyone`}\n                tabIndex={interactive && shown.length !== current ? -1 : 0}\n                onFocus={() => setFocusIndex(shown.length)}\n                className={cn(\n                  \"group/more relative flex rounded-full outline-none\",\n                  \"transition-[scale] duration-150 ease-out-quart active:scale-[0.92] active:duration-75\",\n                  \"focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3\",\n                  // Draws at 24–32px; on touch the target grows to 44px without moving anything.\n                  \"before:absolute before:-inset-2 before:content-[''] pointer-fine:before:hidden\",\n                )}\n              >\n                <Tooltip.Trigger\n                  handle={tip}\n                  payload={{ title: shortNames(hidden) }}\n                  delay={350}\n                  render={\n                    <span\n                      className={cn(\n                        \"grid place-items-center rounded-full bg-fg/[0.08] font-medium leading-none text-fg-2 tabular\",\n                        \"transition-colors duration-150 group-hover/more:bg-fg/[0.13] group-hover/more:text-fg group-data-popup-open/more:bg-fg/[0.13] group-data-popup-open/more:text-fg\",\n                      )}\n                      style={{ width: s, height: s, fontSize: s >= 28 ? 11 : 10.5, paddingLeft: shown.length ? Math.round(overlap / 2) : 0, maskImage: shown.length ? cut : undefined, WebkitMaskImage: shown.length ? cut : undefined }}\n                    />\n                  }\n                >\n                  <NumberFlow value={hidden.length} prefix=\"+\" className=\"leading-none\" />\n                </Tooltip.Trigger>\n              </Popover.Trigger>\n              <Popover.Portal container={container}>\n                <Popover.Positioner side=\"bottom\" align=\"end\" sideOffset={8} collisionPadding={8} className=\"z-(--z-popover)\">\n                  <Popover.Popup\n                    className={cn(\n                      \"flex w-64 max-w-(--available-width) flex-col rounded-xl border border-line-2 bg-raised text-fg shadow-pop outline-none\",\n                      \"origin-(--transform-origin) transition-[opacity,scale,translate,filter] duration-200 ease-out-expo\",\n                      \"data-starting-style:-translate-y-1 data-starting-style:scale-[0.96] data-starting-style:opacity-0 data-starting-style:blur-[2px]\",\n                      \"data-ending-style:scale-[0.98] data-ending-style:opacity-0 data-ending-style:duration-150 data-ending-style:ease-out-quart\",\n                      \"data-instant:transition-none motion-reduce:data-starting-style:translate-y-0 motion-reduce:data-starting-style:scale-100 motion-reduce:data-starting-style:blur-none\",\n                    )}\n                  >\n                    <div className=\"flex h-9 shrink-0 items-center justify-between gap-3 border-b border-line px-3\">\n                      <Popover.Title className=\"truncate text-[12.5px] font-medium text-fg\">{label}</Popover.Title>\n                      <NumberFlow value={people.length} className=\"font-mono text-2xs text-fg-3\" />\n                    </div>\n                    <ul\n                      aria-label={label}\n                      className=\"max-h-[232px] overflow-y-auto overscroll-contain p-1 [mask-image:linear-gradient(to_bottom,transparent,var(--fg)_6px,var(--fg)_calc(100%-6px),transparent)]\"\n                    >\n                      {people.map((p) => {\n                        const active = p.id === activeId;\n                        const row = (\n                          <>\n                            <Face person={p} size={24} active={active} />\n                            <span className=\"min-w-0 flex-1 truncate text-left text-[13px] text-fg\">{displayName(p)}</span>\n                            <span className={cn(\"max-w-[45%] shrink-0 truncate text-[12px]\", active ? \"text-fg\" : \"text-fg-3\")}>\n                              {active ? \"Following\" : p.idle && !p.meta ? \"Idle\" : p.meta}\n                            </span>\n                          </>\n                        );\n                        return (\n                          <li key={p.id}>\n                            {interactive && !p.self ? (\n                              <button\n                                type=\"button\"\n                                aria-pressed={active}\n                                onClick={() => {\n                                  onPersonClick?.(p);\n                                  setOpen(false);\n                                }}\n                                className={cn(\n                                  \"flex h-10 w-full items-center gap-2.5 rounded-lg px-2 outline-none\",\n                                  \"transition-colors duration-150 hover:bg-hover focus-visible:bg-hover\",\n                                  \"focus-visible:outline-solid focus-visible:outline-1 focus-visible:-outline-offset-1 focus-visible:outline-fg-4\",\n                                )}\n                              >\n                                {row}\n                              </button>\n                            ) : (\n                              <div className=\"flex h-10 items-center gap-2.5 px-2\">{row}</div>\n                            )}\n                          </li>\n                        );\n                      })}\n                    </ul>\n                  </Popover.Popup>\n                </Popover.Positioner>\n              </Popover.Portal>\n            </Popover.Root>\n          </motion.span>\n        )}\n      </AnimatePresence>\n\n      {/* One tooltip for the whole stack. It glides between faces instead of closing and reopening. */}\n      <Tooltip.Root handle={tip} disabled={open}>\n        {({ payload }) => (\n          <Tooltip.Portal container={container}>\n            <Tooltip.Positioner\n              side=\"top\"\n              sideOffset={8}\n              collisionPadding={8}\n              className=\"z-(--z-tooltip) h-(--positioner-height) w-(--positioner-width) max-w-(--available-width) transition-[top,left,right,bottom] duration-200 ease-out-quart data-instant:transition-none\"\n            >\n              <Tooltip.Popup\n                className={cn(\n                  \"h-(--popup-height,auto) w-(--popup-width,auto) overflow-clip rounded-lg border border-line-2 bg-raised text-[12px] leading-4 text-fg shadow-pop outline-none\",\n                  \"origin-(--transform-origin) transition-[opacity,scale,width,height] [transition-duration:150ms,150ms,200ms,200ms] ease-out-expo\",\n                  \"data-starting-style:scale-96 data-starting-style:opacity-0 data-ending-style:opacity-0 data-ending-style:[transition-duration:100ms]\",\n                  \"data-instant:transition-none motion-reduce:data-starting-style:scale-100\",\n                )}\n              >\n                <Tooltip.Viewport\n                  className={cn(\n                    \"relative h-full w-full\",\n                    \"[&>*]:flex [&>*]:w-max [&>*]:max-w-[16rem] [&>*]:flex-col [&>*]:px-2 [&>*]:py-[5px]\",\n                    \"[&>[data-previous]]:absolute [&>[data-previous]]:left-0 [&>[data-previous]]:top-0\",\n                    \"[&>*]:transition-[opacity,filter] [&>*]:duration-150\",\n                    \"[&>[data-current][data-starting-style]]:opacity-0 [&>[data-current][data-starting-style]]:blur-[2px]\",\n                    \"[&>[data-previous][data-ending-style]]:opacity-0 [&>[data-previous]]:duration-100\",\n                  )}\n                >\n                  {payload && (\n                    <>\n                      <span className=\"flex items-baseline gap-1.5\">\n                        <span className=\"truncate\">{payload.title}</span>\n                        {payload.meta && <span className=\"shrink-0 text-fg-3\">{payload.meta}</span>}\n                      </span>\n                      {payload.hint && <span className=\"text-2xs text-fg-3\">{payload.hint}</span>}\n                    </>\n                  )}\n                </Tooltip.Viewport>\n              </Tooltip.Popup>\n            </Tooltip.Positioner>\n          </Tooltip.Portal>\n        )}\n      </Tooltip.Root>\n      {live}\n    </div>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/presence-stack"
}
