{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "reaction-bar",
  "type": "registry:ui",
  "title": "Reaction Bar",
  "description": "Emoji reactions that pop, roll their counts, say who, and roll back.",
  "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",
    "https://www.stealth.pm/r/use-controllable-state.json"
  ],
  "files": [
    {
      "path": "components/ui/reaction-bar.tsx",
      "content": "\"use client\";\nimport { Popover } from \"@base-ui/react/popover\";\nimport { Tooltip } from \"@base-ui/react/tooltip\";\nimport NumberFlow from \"@number-flow/react\";\nimport {\n  AnimatePresence,\n  motion,\n  useAnimate,\n  useReducedMotion,\n} from \"motion/react\";\nimport { useEffect, useRef, useState } from \"react\";\nimport { cn } from \"@/lib/cn\";\nimport { ease, spring } from \"@/lib/motion\";\nimport { useControllableState } from \"@/lib/use-controllable-state\";\n\nexport type Reaction = {\n  emoji: string;\n  /** Total people who reacted with it, the viewer included. */\n  count: number;\n  /** Whether the viewer is one of them. */\n  reacted?: boolean;\n  /** Names of some or all of the people, for the tooltip. The viewer is added and removed for you. */\n  people?: string[];\n};\n\nexport type ReactionChoice = { emoji: string; label: string };\n\nexport const defaultChoices: ReactionChoice[] = [\n  { emoji: \"👍\", label: \"Thumbs up\" },\n  { emoji: \"❤️\", label: \"Heart\" },\n  { emoji: \"😂\", label: \"Laughing\" },\n  { emoji: \"🎉\", label: \"Party popper\" },\n  { emoji: \"😮\", label: \"Surprised\" },\n  { emoji: \"🙏\", label: \"Thank you\" },\n  { emoji: \"👀\", label: \"Eyes\" },\n  { emoji: \"🔥\", label: \"Fire\" },\n  { emoji: \"✅\", label: \"Done\" },\n  { emoji: \"🚀\", label: \"Rocket\" },\n  { emoji: \"💯\", label: \"Hundred\" },\n  { emoji: \"👏\", label: \"Clapping\" },\n  { emoji: \"🤔\", label: \"Thinking\" },\n  { emoji: \"😢\", label: \"Sad\" },\n  { emoji: \"👎\", label: \"Thumbs down\" },\n  { emoji: \"💡\", label: \"Idea\" },\n];\n\n/* -------------------------------------------------------------------------------------------------\n * The reducer: one toggle, used for the optimistic change and for its rollback\n * -----------------------------------------------------------------------------------------------*/\n\n/** Adds or removes the viewer's reaction. A reaction nobody has any more disappears. */\nexport function toggleReaction(\n  list: Reaction[],\n  emoji: string,\n  you = \"You\",\n): Reaction[] {\n  const found = list.find((r) => r.emoji === emoji);\n  if (!found)\n    return [...list, { emoji, count: 1, reacted: true, people: [you] }];\n  return list\n    .map((r) => {\n      if (r.emoji !== emoji) return r;\n      const people = (r.people ?? []).filter((p) => p !== you);\n      return r.reacted\n        ? { ...r, reacted: false, count: Math.max(0, r.count - 1), people }\n        : { ...r, reacted: true, count: r.count + 1, people: [you, ...people] };\n    })\n    .filter((r) => r.count > 0);\n}\n\nconst list = new Intl.ListFormat(\"en\", { type: \"conjunction\" });\n\n// \"You, Maya and Jon\", \"Maya, Jon and 4 others\". The viewer always comes first.\nfunction who(r: Reaction, you: string) {\n  const names = [\n    ...(r.reacted ? [you] : []),\n    ...(r.people ?? []).filter((p) => p !== you),\n  ];\n  const shown = names.slice(0, 3);\n  const rest = Math.max(0, r.count - shown.length);\n  if (!shown.length) return r.count === 1 ? \"1 person\" : `${r.count} people`;\n  return rest > 0\n    ? `${shown.join(\", \")} and ${rest} ${rest === 1 ? \"other\" : \"others\"}`\n    : list.format(shown);\n}\n\n/* -------------------------------------------------------------------------------------------------\n * ReactionBar\n * -----------------------------------------------------------------------------------------------*/\n\ntype Tip = { title: string; sub?: string; emoji?: string };\n\nexport type ReactionBarProps = Omit<\n  React.ComponentProps<\"div\">,\n  \"defaultValue\" | \"onChange\"\n> & {\n  value?: Reaction[];\n  defaultValue?: Reaction[];\n  /** Fires on every change, including a rollback after a failed save. */\n  onValueChange?: (next: Reaction[]) => void;\n  /** Persist one toggle. Return a promise; if it rejects, that toggle is undone and the bar says so. */\n  onReact?: (emoji: string, reacted: boolean) => unknown;\n  /** What the picker offers, in order. */\n  choices?: ReactionChoice[];\n  /** How the viewer appears in the people list. */\n  you?: string;\n  size?: \"sm\" | \"md\";\n  /** Shows the reactions without letting anyone change them: archived threads, signed-out views. */\n  readOnly?: boolean;\n  /** Where the picker and tooltips portal. Defaults to document.body. */\n  container?: HTMLElement | React.RefObject<HTMLElement | null> | null;\n};\n\n/**\n * Reactions under a message. Yours pops and its count rolls; a new one springs into the row\n * while its neighbors make room; the tooltip says who. Saves are optimistic and roll back.\n */\nexport function ReactionBar({\n  value: valueProp,\n  defaultValue = [],\n  onValueChange,\n  onReact,\n  choices = defaultChoices,\n  you = \"You\",\n  size = \"md\",\n  readOnly = false,\n  container,\n  className,\n  ...rest\n}: ReactionBarProps) {\n  const reduce = useReducedMotion();\n  const [value, setValue] = useControllableState({\n    value: valueProp,\n    defaultValue,\n    onChange: onValueChange,\n  });\n  const [tip] = useState(() => Tooltip.createHandle<Tip>());\n  const [open, setOpen] = useState(false);\n  const [error, setError] = useState<{ emoji: string; message: string } | null>(\n    null,\n  );\n  const [bumps, setBumps] = useState<Record<string, number>>({});\n  const latest = useRef(value);\n  const errorTimer = useRef<number>(undefined);\n  useEffect(() => {\n    latest.current = value;\n  }, [value]);\n  useEffect(() => () => window.clearTimeout(errorTimer.current), []);\n\n  const label = (emoji: string) =>\n    choices.find((c) => c.emoji === emoji)?.label ?? emoji;\n\n  const react = (emoji: string) => {\n    const before = latest.current.find((r) => r.emoji === emoji);\n    const adding = !before?.reacted;\n    const next = toggleReaction(latest.current, emoji, you);\n    latest.current = next;\n    setValue(next);\n    setError(null);\n    if (adding) setBumps((b) => ({ ...b, [emoji]: (b[emoji] ?? 0) + 1 }));\n    if (!onReact) return;\n    Promise.resolve()\n      .then(() => onReact(emoji, adding))\n      .catch(() => {\n        // Undo just this toggle, on top of whatever else has changed since.\n        const undone = toggleReaction(latest.current, emoji, you);\n        latest.current = undone;\n        setValue(undone);\n        setError({\n          emoji,\n          message: `Couldn’t ${adding ? \"add\" : \"remove\"} ${label(emoji).toLowerCase()}`,\n        });\n        window.clearTimeout(errorTimer.current);\n        errorTimer.current = window.setTimeout(() => setError(null), 5000);\n      });\n  };\n\n  const h = size === \"sm\" ? \"h-6 text-[11.5px]\" : \"h-7 text-[12px]\";\n  const chipBase = cn(\n    \"relative inline-flex items-center gap-1.5 rounded-full border px-2 font-medium tabular outline-none select-none\",\n    \"transition-[background-color,border-color,color,scale] duration-150 ease-out active:scale-[0.94] active:duration-75\",\n    \"focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3\",\n    // Chips draw at 24–28px; on touch each grows a 44px target without moving.\n    \"before:absolute before:-inset-y-2 before:inset-x-0 before:content-[''] pointer-fine:before:hidden\",\n    h,\n  );\n\n  return (\n    <div\n      role=\"group\"\n      aria-label=\"Reactions\"\n      className={cn(\"flex flex-wrap items-center gap-1.5\", className)}\n      {...rest}\n    >\n      <AnimatePresence initial={false} mode=\"popLayout\">\n        {value.map((r) => (\n          <motion.span\n            key={r.emoji}\n            layout={reduce ? false : \"position\"}\n            initial={\n              reduce\n                ? { opacity: 0 }\n                : { opacity: 0, scale: 0.6, filter: \"blur(2px)\" }\n            }\n            animate={{ opacity: 1, scale: 1, filter: \"blur(0px)\" }}\n            exit={\n              reduce\n                ? { opacity: 0, transition: { duration: 0.1 } }\n                : {\n                    opacity: 0,\n                    scale: 0.6,\n                    filter: \"blur(2px)\",\n                    transition: { duration: 0.14, ease: ease.out },\n                  }\n            }\n            transition={\n              reduce\n                ? { duration: 0.15 }\n                : { ...spring.pop, layout: spring.snappy }\n            }\n            className=\"flex\"\n          >\n            <Tooltip.Trigger\n              handle={tip}\n              payload={{\n                title: who(r, you),\n                sub: `reacted with ${label(r.emoji).toLowerCase()}`,\n                emoji: r.emoji,\n              }}\n              delay={400}\n              disabled={open}\n              render={\n                <Chip\n                  reaction={r}\n                  label={label(r.emoji)}\n                  bump={bumps[r.emoji] ?? 0}\n                  failed={error?.emoji === r.emoji}\n                  readOnly={readOnly}\n                  className={chipBase}\n                  onClick={() => react(r.emoji)}\n                />\n              }\n            />\n          </motion.span>\n        ))}\n\n        {!readOnly && (\n          <motion.span\n            key=\"add\"\n            layout={reduce ? false : \"position\"}\n            transition={spring.snappy}\n            className=\"flex\"\n          >\n            <Popover.Root open={open} onOpenChange={setOpen}>\n              <Popover.Trigger\n                aria-label=\"Add reaction\"\n                className={cn(\n                  chipBase,\n                  \"group/add justify-center border-line-2 bg-raised text-fg-3 hover:border-fg-4 hover:bg-hover hover:text-fg data-popup-open:border-fg-4 data-popup-open:bg-hover data-popup-open:text-fg\",\n                  size === \"sm\" ? \"w-8 px-0\" : \"w-9 px-0\",\n                )}\n              >\n                <Tooltip.Trigger\n                  handle={tip}\n                  payload={{ title: \"Add reaction\" }}\n                  delay={400}\n                  disabled={open}\n                  render={\n                    <span className=\"grid size-full place-items-center\" />\n                  }\n                >\n                  <AddGlyph size={size === \"sm\" ? 14 : 16} />\n                </Tooltip.Trigger>\n              </Popover.Trigger>\n              <Popover.Portal container={container}>\n                <Popover.Positioner\n                  side=\"top\"\n                  align=\"start\"\n                  sideOffset={8}\n                  collisionPadding={8}\n                  className=\"z-(--z-popover)\"\n                >\n                  <Popover.Popup\n                    className={cn(\n                      \"rounded-xl border border-line-2 bg-raised p-1.5 text-fg shadow-pop outline-none\",\n                      \"origin-(--transform-origin) transition-[opacity,scale,translate,filter] duration-180 ease-out-expo\",\n                      \"data-starting-style:scale-[0.94] data-starting-style:opacity-0 data-starting-style:blur-[2px]\",\n                      \"data-[side=top]:data-starting-style:translate-y-1 data-[side=bottom]:data-starting-style:-translate-y-1\",\n                      \"data-ending-style:scale-[0.97] data-ending-style:opacity-0 data-ending-style:duration-120 data-ending-style:ease-out-quart\",\n                      \"data-instant:transition-none motion-reduce:data-starting-style:scale-100 motion-reduce:data-starting-style:translate-y-0 motion-reduce:data-starting-style:blur-none\",\n                    )}\n                  >\n                    <Popover.Title className=\"sr-only\">\n                      Add reaction\n                    </Popover.Title>\n                    <Picker\n                      choices={choices}\n                      mine={\n                        new Set(\n                          value.filter((r) => r.reacted).map((r) => r.emoji),\n                        )\n                      }\n                      onPick={(emoji) => {\n                        react(emoji);\n                        setOpen(false);\n                      }}\n                    />\n                  </Popover.Popup>\n                </Popover.Positioner>\n              </Popover.Portal>\n            </Popover.Root>\n          </motion.span>\n        )}\n      </AnimatePresence>\n\n      <AnimatePresence initial={false}>\n        {error && (\n          // Its own line, opened to height, so the thread below eases down instead of jumping.\n          <motion.span\n            key=\"error\"\n            initial={{ height: 0, opacity: 0 }}\n            animate={{ height: \"auto\", opacity: 1 }}\n            exit={{\n              height: 0,\n              opacity: 0,\n              transition: { duration: reduce ? 0 : 0.16, ease: ease.inOut },\n            }}\n            transition={\n              reduce\n                ? { duration: 0 }\n                : {\n                    height: { duration: 0.22, ease: ease.inOut },\n                    opacity: { duration: 0.18, ease: ease.out },\n                  }\n            }\n            className=\"-mt-1.5 basis-full overflow-hidden\"\n          >\n            <span className=\"flex items-center gap-1.5 pl-1 pt-1.5 text-[12px] text-danger\">\n              {error.message}\n              <button\n                type=\"button\"\n                onClick={() => react(error.emoji)}\n                className=\"rounded-sm font-medium text-fg underline decoration-fg-4 underline-offset-[3px] outline-none transition-[text-decoration-color] duration-150 hover:decoration-fg-2 focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3\"\n              >\n                Try again\n              </button>\n            </span>\n          </motion.span>\n        )}\n      </AnimatePresence>\n      <span role=\"status\" aria-live=\"polite\" className=\"sr-only\">\n        {error ? `${error.message}. Try again.` : \"\"}\n      </span>\n\n      {/* One tooltip for the row. It glides between chips instead of closing and reopening. */}\n      <Tooltip.Root handle={tip}>\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-[15rem] [&>*]:items-center [&>*]:gap-2 [&>*]: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                      {payload.emoji && (\n                        <span className=\"shrink-0 text-[20px] leading-none\">\n                          {payload.emoji}\n                        </span>\n                      )}\n                      <span className=\"flex min-w-0 flex-col\">\n                        <span className=\"text-pretty\">{payload.title}</span>\n                        {payload.sub && (\n                          <span className=\"text-fg-3\">{payload.sub}</span>\n                        )}\n                      </span>\n                    </>\n                  )}\n                </Tooltip.Viewport>\n              </Tooltip.Popup>\n            </Tooltip.Positioner>\n          </Tooltip.Portal>\n        )}\n      </Tooltip.Root>\n    </div>\n  );\n}\n\n/* -------------------------------------------------------------------------------------------------\n * Chip\n * -----------------------------------------------------------------------------------------------*/\n\ntype ChipProps = Omit<React.ComponentProps<\"button\">, \"children\"> & {\n  reaction: Reaction;\n  label: string;\n  /** Increments each time the viewer adds this reaction; each increment pops the emoji once. */\n  bump: number;\n  failed: boolean;\n  readOnly: boolean;\n};\n\nfunction Chip({\n  reaction: r,\n  label,\n  bump,\n  failed,\n  readOnly,\n  className,\n  ...rest\n}: ChipProps) {\n  const reduce = useReducedMotion();\n  const [scope, animate] = useAnimate<HTMLSpanElement>();\n  const seen = useRef(bump);\n\n  useEffect(() => {\n    if (bump === seen.current) return;\n    seen.current = bump;\n    if (reduce || !scope.current) return;\n    // Up, a little past, settle: the emoji takes the press like something soft.\n    animate(\n      scope.current,\n      { scale: [1, 1.38, 0.92, 1], rotate: [0, -8, 4, 0] },\n      { duration: 0.46, ease: ease.out, times: [0, 0.3, 0.65, 1] },\n    );\n  }, [bump, reduce, animate, scope]);\n\n  const count = `${r.count} ${r.count === 1 ? \"reaction\" : \"reactions\"}`;\n  return (\n    <button\n      type=\"button\"\n      aria-pressed={r.reacted ?? false}\n      aria-label={`${label}, ${count}${r.reacted ? \", including you\" : \"\"}`}\n      aria-disabled={readOnly || undefined}\n      data-reacted={r.reacted || undefined}\n      data-failed={failed || undefined}\n      className={cn(\n        className,\n        \"border-line-2 bg-raised text-fg-2 hover:border-fg-4 hover:bg-hover hover:text-fg\",\n        \"data-reacted:border-fg/30 data-reacted:bg-fg/[0.08] data-reacted:text-fg data-reacted:hover:bg-fg/[0.12]\",\n        \"data-failed:border-danger/40 data-failed:text-danger\",\n        // Read-only chips still take focus and hover, so the names stay reachable; they just don't press.\n        readOnly &&\n          \"cursor-default active:scale-100 hover:border-line-2 hover:bg-raised data-reacted:hover:bg-fg/[0.08]\",\n      )}\n      {...rest}\n      onClick={readOnly ? undefined : rest.onClick}\n    >\n      <span\n        ref={scope}\n        aria-hidden\n        className=\"inline-block text-[1.2em] leading-none\"\n      >\n        {r.emoji}\n      </span>\n      <NumberFlow value={r.count} aria-hidden className=\"leading-none\" />\n    </button>\n  );\n}\n\n/* -------------------------------------------------------------------------------------------------\n * Picker: a grid with arrow keys, one tab stop\n * -----------------------------------------------------------------------------------------------*/\n\nconst COLUMNS = 8;\n\nfunction Picker({\n  choices,\n  mine,\n  onPick,\n}: {\n  choices: ReactionChoice[];\n  mine: Set<string>;\n  onPick: (emoji: string) => void;\n}) {\n  const [active, setActive] = useState(0);\n  const grid = useRef<HTMLDivElement>(null);\n\n  const move = (e: React.KeyboardEvent) => {\n    const last = choices.length - 1;\n    const next =\n      e.key === \"ArrowRight\"\n        ? Math.min(last, active + 1)\n        : e.key === \"ArrowLeft\"\n          ? Math.max(0, active - 1)\n          : e.key === \"ArrowDown\"\n            ? Math.min(last, active + COLUMNS)\n            : e.key === \"ArrowUp\"\n              ? Math.max(0, active - COLUMNS)\n              : e.key === \"Home\"\n                ? 0\n                : e.key === \"End\"\n                  ? last\n                  : -1;\n    if (next < 0) return;\n    e.preventDefault();\n    setActive(next);\n    grid.current\n      ?.querySelectorAll<HTMLElement>(\"[role=gridcell]\")\n      [next]?.focus();\n  };\n\n  const rows = Array.from(\n    { length: Math.ceil(choices.length / COLUMNS) },\n    (_, i) => choices.slice(i * COLUMNS, i * COLUMNS + COLUMNS),\n  );\n  return (\n    <div\n      ref={grid}\n      role=\"grid\"\n      aria-label=\"Reactions\"\n      onKeyDown={move}\n      className=\"flex flex-col gap-0.5\"\n    >\n      {rows.map((row, ri) => (\n        <div key={ri} role=\"row\" className=\"flex gap-0.5\">\n          {row.map((c, ci) => {\n            const i = ri * COLUMNS + ci;\n            const on = mine.has(c.emoji);\n            return (\n              <button\n                key={c.emoji}\n                type=\"button\"\n                role=\"gridcell\"\n                aria-label={c.label}\n                aria-selected={on}\n                tabIndex={i === active ? 0 : -1}\n                onFocus={() => setActive(i)}\n                onClick={() => onPick(c.emoji)}\n                className={cn(\n                  \"group/cell grid size-8 place-items-center rounded-lg outline-none\",\n                  \"transition-[background-color,scale] duration-150 ease-out hover:bg-hover active:scale-[0.9] active:duration-75\",\n                  \"focus-visible:bg-hover focus-visible:outline-solid focus-visible:outline-1 focus-visible:-outline-offset-1 focus-visible:outline-fg-4\",\n                  on &&\n                    \"bg-fg/[0.08] shadow-[inset_0_0_0_1px_color-mix(in_oklab,var(--fg)_22%,transparent)]\",\n                )}\n              >\n                <span\n                  aria-hidden\n                  className=\"text-[18px] leading-none transition-transform duration-200 ease-out-expo group-hover/cell:scale-[1.18] group-focus-visible/cell:scale-[1.18] motion-reduce:transition-none motion-reduce:group-hover/cell:scale-100\"\n                >\n                  {c.emoji}\n                </span>\n              </button>\n            );\n          })}\n        </div>\n      ))}\n    </div>\n  );\n}\n\nfunction AddGlyph({ size }: { size: number }) {\n  // A face with a plus where its shoulder would be: \"add a reaction\", on the 16px grid.\n  return (\n    <svg\n      width={size}\n      height={size}\n      viewBox=\"0 0 16 16\"\n      fill=\"none\"\n      stroke=\"currentColor\"\n      strokeWidth={1.4}\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      aria-hidden\n    >\n      <path d=\"M13.2 8.6A5.75 5.75 0 1 1 7.4 2.3\" />\n      <path d=\"M5.6 9.5c.55.8 1.4 1.25 2.4 1.25s1.85-.45 2.4-1.25\" />\n      <circle cx=\"5.9\" cy=\"6.6\" r=\".6\" fill=\"currentColor\" stroke=\"none\" />\n      <circle cx=\"10.1\" cy=\"6.6\" r=\".6\" fill=\"currentColor\" stroke=\"none\" />\n      <path\n        d=\"M12.25 1.75v3.5M10.5 3.5H14\"\n        className=\"transition-transform duration-200 ease-out-expo [transform-box:fill-box] [transform-origin:center] group-hover/add:rotate-90 motion-reduce:transition-none\"\n      />\n    </svg>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/reaction-bar"
}
