{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "reactions",
  "type": "registry:ui",
  "title": "Reactions",
  "description": "Acknowledge a post in one tap, with counts that roll.",
  "categories": [
    "actions"
  ],
  "dependencies": [
    "@radix-ui/react-popover",
    "lucide-react",
    "motion"
  ],
  "registryDependencies": [
    "https://irisui.dev/r/foundation.json",
    "https://irisui.dev/r/motion-tokens.json",
    "https://irisui.dev/r/utils.json"
  ],
  "files": [
    {
      "path": "src/registry/components/reactions/reactions.tsx",
      "content": "\"use client\";\n\nimport { useEffect, useId, useLayoutEffect, useRef, useState, useSyncExternalStore } from \"react\";\nimport type { KeyboardEvent as ReactKeyboardEvent, RefObject } from \"react\";\nimport * as PopoverPrimitive from \"@radix-ui/react-popover\";\nimport { AnimatePresence, LayoutGroup, animate, motion, useMotionValue, useReducedMotion } from \"motion/react\";\nimport type { AnimationPlaybackControls, TargetAndTransition, Transition, Variants } from \"motion/react\";\nimport { Check, SmilePlus, X } from \"lucide-react\";\nimport type { LucideIcon } from \"lucide-react\";\nimport { motionTokens } from \"../lib/motion-tokens\";\nimport { cn } from \"../lib/utils\";\n\n/* Compact pills: an icon and a tabular count. The viewer's own reactions take the accent and a filled glyph, so the state never\n   rests on colour alone. Pill and add button share one shape; a hovered or focused one rises above its neighbours for its tip. */\nconst chipClass =\n  \"group/pill relative m-0 inline-flex h-8 flex-none cursor-pointer touch-manipulation items-center rounded-button border border-line bg-surface text-ink-2 [-webkit-tap-highlight-color:transparent] transition-[background-color,border-color,color] duration-fast ease-standard hover:z-2 hover:border-line-strong hover:bg-hover hover:text-ink focus-visible:z-2 motion-reduce:transition-none\";\nconst pillClass = cn(\n  chipClass,\n  \"gap-1.5 pr-[11px] pl-[9px] text-sm leading-none font-medium tracking-body tabular-nums active:bg-hover aria-pressed:border-[color-mix(in_oklab,var(--accent)_45%,var(--line))] aria-pressed:bg-accent-tint aria-pressed:text-accent-ink aria-pressed:hover:border-[color-mix(in_oklab,var(--accent)_65%,var(--line))] aria-pressed:hover:bg-[color-mix(in_oklab,var(--accent)_17%,var(--surface))] aria-pressed:hover:text-accent-ink aria-pressed:active:bg-[color-mix(in_oklab,var(--accent)_18%,var(--surface))]\",\n);\n/* The add button anchors the picker, so it answers a press with colour and never scales. */\nconst addClass = cn(\n  chipClass,\n  \"w-[38px] justify-center p-0 text-ink-3 active:bg-hover active:text-ink data-[state=open]:border-line-strong data-[state=open]:bg-hover data-[state=open]:text-ink\",\n);\n/* The label appears after a short hover (or keyboard focus on a pill). It floats, so it earns a quiet shadow. */\nconst tipClass =\n  \"pointer-events-none absolute bottom-[calc(100%+8px)] left-1/2 z-1 rounded-full border border-line bg-surface px-[9px] py-[5px] text-xs leading-[1.2] font-normal tracking-body whitespace-nowrap text-ink opacity-0 shadow-raised [transform:translate(-50%,3px)] [transition:opacity_var(--duration-instant)_var(--ease-standard),transform_var(--duration-fast)_var(--ease-standard)] group-hover/pill:opacity-100 group-hover/pill:[transform:translate(-50%,0)] group-hover/pill:delay-[.45s] motion-reduce:transition-none\";\nconst pillTipClass = cn(\n  tipClass,\n  \"group-focus-visible/pill:opacity-100 group-focus-visible/pill:[transform:translate(-50%,0)] group-focus-visible/pill:delay-200\",\n);\n/* Chosen options carry a ring as well as the tint, so they never read as the hover highlight. */\nconst optionClass =\n  \"relative grid size-10 flex-none cursor-pointer place-items-center rounded-button border-0 bg-transparent p-0 text-ink-2 [-webkit-tap-highlight-color:transparent] transition-[color,background-color] duration-fast ease-standard hover:text-ink aria-pressed:bg-accent-tint aria-pressed:text-accent-ink aria-pressed:shadow-[inset_0_0_0_1px_color-mix(in_oklab,var(--accent)_45%,transparent)] aria-pressed:hover:text-accent-ink aria-pressed:[&_svg]:fill-[color-mix(in_oklab,currentColor_22%,transparent)] motion-reduce:transition-none\";\nconst glyphGridClass = \"grid size-4 flex-none place-items-center\";\n\n/**\n * A reaction bar for comments, posts, and release notes. Each pill shows one reaction and how many people chose it; the viewer's own reactions carry the accent.\n * Tapping a pill adds or removes the viewer's reaction, and the add button opens a compact picker that grows from it. Use it for quick acknowledgement;\n * when people need to say something specific, give them a reply field instead.\n */\nexport interface ReactionOption {\n  id: string;\n  label: string;\n  icon: LucideIcon;\n}\nexport interface Reaction {\n  id: string;\n  count: number;\n  reacted: boolean;\n}\nexport interface ReactionsProps {\n  /** Every reaction the picker offers, in picker order. */\n  options: ReactionOption[];\n  /** Controlled reactions. Pills keep this order, and a reaction whose count reaches zero leaves the bar. */\n  value?: Reaction[];\n  defaultValue?: Reaction[];\n  /** Called with the next reactions and the change that produced them. */\n  onValueChange?: (next: Reaction[], change: { id: string; reacted: boolean }) => void;\n  /** Accessible name for the bar. */\n  label?: string;\n  className?: string;\n}\n\nconst enter = [...motionTokens.ease.enter] as [number, number, number, number];\nconst standard = [...motionTokens.ease.standard] as [number, number, number, number];\nconst blur = (px: number) => `blur(${px}px)`;\nconst noop = () => () => {};\n\n/** Reduced motion only counts after hydration, so the server and the first client render always agree. */\nfunction useReduced() {\n  const hydrated = useSyncExternalStore(\n    noop,\n    () => true,\n    () => false,\n  );\n  const reduced = useReducedMotion() ?? false;\n  return hydrated && reduced;\n}\n\n/** Follows the natural size of `content`: after `key` changes the size springs to it; any other resize (a late font, a reflow) jumps, so nothing wobbles on load. */\nfunction useFollowSize(\n  content: RefObject<HTMLElement | null>,\n  axis: \"width\" | \"height\",\n  key: string,\n  reduced: boolean,\n  transition: Transition,\n) {\n  const size = useMotionValue<number | \"auto\">(\"auto\");\n  const lastKey = useRef(key),\n    armedUntil = useRef(0);\n  useLayoutEffect(() => {\n    if (lastKey.current === key) return;\n    lastKey.current = key;\n    armedUntil.current = performance.now() + 700;\n  }, [key]);\n  useEffect(() => {\n    const node = content.current;\n    if (!node || typeof ResizeObserver === \"undefined\") return;\n    let measured = false;\n    let sizing: AnimationPlaybackControls | undefined;\n    // Border-box sizes ignore transforms, so a pill that is still scaling in reports its real width.\n    const observer = new ResizeObserver(([entry]) => {\n      if (!entry) return;\n      const box = entry.borderBoxSize?.[0];\n      const next = box\n        ? axis === \"width\"\n          ? box.inlineSize\n          : box.blockSize\n        : axis === \"width\"\n          ? node.offsetWidth\n          : node.offsetHeight;\n      sizing?.stop();\n      if (!measured || reduced || performance.now() > armedUntil.current) {\n        measured = true;\n        size.jump(next);\n        return;\n      }\n      sizing = animate(size, next, transition);\n    });\n    observer.observe(node, { box: \"border-box\" });\n    return () => {\n      observer.disconnect();\n      sizing?.stop();\n    };\n  }, [axis, content, reduced, size, transition]);\n  return size;\n}\n\n/** Digits roll in the direction the count moved: up and out when it grows, down when it shrinks. Only the digits that change move. */\nconst digitMotion: Variants = {\n  enter: (direction: number) => ({ opacity: 0, y: `${direction * 0.55}em`, filter: blur(motionTokens.blur.subtle) }),\n  center: { opacity: 1, y: \"0em\", filter: blur(0) },\n  exit: (direction: number) => ({\n    opacity: 0,\n    y: `${direction * -0.55}em`,\n    filter: blur(motionTokens.blur.subtle),\n    transition: { duration: motionTokens.duration.fast, ease: standard },\n  }),\n};\nconst digitStill: Variants = {\n  enter: { opacity: 1 },\n  center: { opacity: 1 },\n  exit: { opacity: 0, transition: { duration: 0 } },\n};\nconst digitTransition = {\n  y: motionTokens.spring.snappy,\n  opacity: { duration: motionTokens.duration.fast, ease: enter },\n  filter: { duration: motionTokens.duration.fast, ease: enter },\n};\n\nfunction RollingCount({ value, reduced }: { value: number; reduced: boolean }) {\n  const [roll, setRoll] = useState({ value, direction: 1 });\n  if (roll.value !== value) setRoll({ value, direction: value > roll.value ? 1 : -1 });\n  const row = useRef<HTMLSpanElement>(null);\n  const text = String(value);\n  const width = useFollowSize(row, \"width\", text, reduced, motionTokens.spring.morph);\n  return (\n    <motion.span className=\"inline-flex min-w-0\" style={{ width }} aria-hidden=\"true\">\n      <span ref={row} className=\"relative inline-flex w-max\">\n        <AnimatePresence mode=\"popLayout\" initial={false} custom={roll.direction}>\n          {text.split(\"\").map((digit, index) => (\n            <motion.span\n              key={`${text.length - index}:${digit}`}\n              className=\"inline-block\"\n              custom={roll.direction}\n              variants={reduced ? digitStill : digitMotion}\n              initial=\"enter\"\n              animate=\"center\"\n              exit=\"exit\"\n              transition={reduced ? { duration: 0 } : digitTransition}\n            >\n              {digit}\n            </motion.span>\n          ))}\n        </AnimatePresence>\n      </span>\n    </motion.span>\n  );\n}\n\n/** An impulse rather than a keyframe: the icon starts at rest with velocity, overshoots, and settles. A quick second tap adds to the motion instead of restarting it. */\nconst kick = { type: \"spring\", stiffness: 420, damping: 39 } as const;\n\nfunction KickIcon({ icon: Icon, pop, reduced }: { icon: LucideIcon; pop: number; reduced: boolean }) {\n  const scale = useMotionValue(1),\n    rotate = useMotionValue(0);\n  const seen = useRef(0);\n  useEffect(() => {\n    if (pop <= seen.current) return;\n    seen.current = pop;\n    if (reduced) return;\n    animate(scale, 1, { ...kick, velocity: scale.getVelocity() + 11 });\n    animate(rotate, 0, { ...kick, velocity: rotate.getVelocity() - 420 });\n  }, [pop, reduced, rotate, scale]);\n  useEffect(\n    () => () => {\n      scale.stop();\n      rotate.stop();\n    },\n    [rotate, scale],\n  );\n  return (\n    <motion.span\n      className={cn(\n        glyphGridClass,\n        \"group-aria-pressed/pill:[&_svg]:fill-[color-mix(in_oklab,currentColor_22%,transparent)]\",\n      )}\n      style={{ scale, rotate }}\n      aria-hidden=\"true\"\n    >\n      <Icon size={16} strokeWidth={1.75} />\n    </motion.span>\n  );\n}\n\nconst rest: TargetAndTransition = { opacity: 1, scale: 1, y: 0, filter: blur(0) };\nconst fadeIn: TargetAndTransition = { opacity: 0 };\nconst fadeOut: TargetAndTransition = { opacity: 0, transition: { duration: motionTokens.duration.instant } };\nconst pillIn: TargetAndTransition = { opacity: 0, scale: 0.5, filter: blur(motionTokens.blur.soft) };\nconst pillOut: TargetAndTransition = {\n  opacity: 0,\n  scale: 0.6,\n  filter: blur(motionTokens.blur.subtle),\n  transition: { duration: motionTokens.duration.fast, ease: standard },\n};\nconst pillTransition = {\n  layout: motionTokens.spring.morph,\n  scale: motionTokens.spring.snappy,\n  opacity: { duration: motionTokens.duration.fast, ease: enter },\n  filter: { duration: motionTokens.duration.fast, ease: enter },\n};\nconst iconIn: TargetAndTransition = { opacity: 0, scale: 0.6, filter: blur(motionTokens.blur.subtle) };\nconst iconOut: TargetAndTransition = {\n  ...iconIn,\n  transition: { duration: motionTokens.duration.fast, ease: standard },\n};\nconst iconEnter = {\n  ...motionTokens.spring.snappy,\n  opacity: { duration: motionTokens.duration.fast, ease: enter },\n  filter: { duration: motionTokens.duration.fast, ease: enter },\n};\nconst textIn: TargetAndTransition = { opacity: 0, y: \"0.3em\", filter: blur(motionTokens.blur.soft) };\nconst textOut: TargetAndTransition = {\n  opacity: 0,\n  y: \"-0.3em\",\n  filter: blur(motionTokens.blur.subtle),\n  transition: { duration: motionTokens.duration.fast, ease: standard },\n};\n\n/** The tray of every option. A shared highlight glides to the hovered or focused option, and a caption names it, so an icon never has to explain itself. */\nfunction Picker({\n  options,\n  reacted,\n  reduced,\n  onPick,\n}: {\n  options: ReactionOption[];\n  reacted: Set<string>;\n  reduced: boolean;\n  onPick: (id: string) => void;\n}) {\n  const [focusIndex, setFocusIndex] = useState(0);\n  const [hovered, setHovered] = useState<number | null>(null);\n  const active = Math.min(hovered ?? focusIndex, options.length - 1);\n  const buttons = useRef<(HTMLButtonElement | null)[]>([]);\n  const x = useMotionValue(0);\n  const placed = useRef(false);\n  useLayoutEffect(() => {\n    const target = buttons.current[active]?.offsetLeft ?? 0;\n    if (!placed.current || reduced) {\n      placed.current = true;\n      x.jump(target);\n      return;\n    }\n    // A new target retargets the running spring and keeps its velocity, so a fast sweep glides instead of stepping.\n    animate(x, target, motionTokens.spring.morph);\n  }, [active, reduced, x]);\n  useEffect(() => () => x.stop(), [x]);\n\n  function onKeyDown(event: ReactKeyboardEvent<HTMLDivElement>) {\n    const steps: Record<string, number> = { ArrowRight: 1, ArrowDown: 1, ArrowLeft: -1, ArrowUp: -1 };\n    const count = options.length;\n    const next =\n      event.key in steps\n        ? (focusIndex + steps[event.key]! + count) % count\n        : event.key === \"Home\"\n          ? 0\n          : event.key === \"End\"\n            ? count - 1\n            : -1;\n    if (next < 0) return;\n    event.preventDefault();\n    setHovered(null);\n    setFocusIndex(next);\n    buttons.current[next]?.focus();\n  }\n\n  const option = options[active];\n  const chosen = option ? reacted.has(option.id) : false;\n  return (\n    <>\n      {/* oxlint-disable-next-line jsx-a11y/interactive-supports-focus -- toolbar pattern: focus moves between the options with a roving tabindex, so the container itself is not a tab stop */}\n      <div\n        className=\"relative isolate flex gap-0.5\"\n        role=\"toolbar\"\n        aria-label=\"Reactions\"\n        aria-orientation=\"horizontal\"\n        onKeyDown={onKeyDown}\n        onPointerLeave={() => setHovered(null)}\n      >\n        <motion.span\n          className=\"absolute top-0 left-0 -z-1 size-10 rounded-button bg-hover\"\n          style={{ x }}\n          aria-hidden=\"true\"\n        />\n        {options.map((item, index) => {\n          const Icon = item.icon;\n          const lit = index === active;\n          return (\n            <motion.button\n              key={item.id}\n              ref={(node) => {\n                buttons.current[index] = node;\n              }}\n              type=\"button\"\n              className={optionClass}\n              aria-label={item.label}\n              aria-pressed={reacted.has(item.id)}\n              tabIndex={index === focusIndex ? 0 : -1}\n              onFocus={() => setFocusIndex(index)}\n              onPointerMove={(event) => {\n                if (event.pointerType === \"mouse\" && hovered !== index) setHovered(index);\n              }}\n              onClick={() => onPick(item.id)}\n              initial={reduced ? fadeIn : iconIn}\n              animate={rest}\n              transition={\n                reduced\n                  ? { duration: motionTokens.duration.fast }\n                  : {\n                      ...iconEnter,\n                      delay: 0.04 + index * motionTokens.stagger.item,\n                      opacity: { ...iconEnter.opacity, delay: 0.04 + index * motionTokens.stagger.item },\n                      filter: { ...iconEnter.filter, delay: 0.04 + index * motionTokens.stagger.item },\n                    }\n              }\n            >\n              <motion.span\n                className=\"grid place-items-center\"\n                animate={{ scale: lit && !reduced ? 1.14 : 1, y: lit && !reduced ? -1 : 0 }}\n                transition={motionTokens.spring.snappy}\n              >\n                <Icon size={20} strokeWidth={1.75} aria-hidden=\"true\" />\n              </motion.span>\n            </motion.button>\n          );\n        })}\n      </div>\n      <div\n        className=\"relative flex h-[22px] items-center justify-center text-xs leading-none text-ink-2\"\n        aria-hidden=\"true\"\n      >\n        <AnimatePresence mode=\"popLayout\" initial={false}>\n          {option && (\n            <motion.span\n              key={`${option.id}:${chosen}`}\n              className=\"inline-flex items-center gap-1 whitespace-nowrap\"\n              initial={reduced ? fadeIn : textIn}\n              animate={rest}\n              exit={reduced ? fadeOut : textOut}\n              transition={{\n                duration: reduced ? motionTokens.duration.fast : motionTokens.duration.standard,\n                ease: enter,\n              }}\n            >\n              {chosen && <Check size={12} strokeWidth={2} />}\n              {option.label}\n            </motion.span>\n          )}\n        </AnimatePresence>\n      </div>\n    </>\n  );\n}\n\nexport function Reactions({\n  options,\n  value,\n  defaultValue = [],\n  onValueChange,\n  label = \"Reactions\",\n  className,\n}: ReactionsProps) {\n  const reduced = useReduced();\n  const groupId = useId();\n  const [own, setOwn] = useState(defaultValue);\n  const list = value ?? own;\n  const [pops, setPops] = useState<Record<string, number>>({});\n  const [announcement, setAnnouncement] = useState(\"\");\n  const [open, setOpen] = useState(false);\n  const bar = useRef<HTMLDivElement>(null);\n  const trigger = useRef<HTMLButtonElement>(null);\n  const refocus = useRef<string | null>(null);\n  const byId = new Map(options.map((option) => [option.id, option]));\n  const shown = list.filter((reaction) => reaction.count > 0 && byId.has(reaction.id));\n  const reacted = new Set(shown.filter((reaction) => reaction.reacted).map((reaction) => reaction.id));\n  // Wrapping onto a new row changes the bar's height; the height follows on a spring instead of shoving the content below.\n  const height = useFollowSize(\n    bar,\n    \"height\",\n    shown.map((reaction) => `${reaction.id}${reaction.count}`).join(),\n    reduced,\n    motionTokens.spring.smooth,\n  );\n\n  // A pill that leaves while focused hands focus to its neighbour, or to the add button, so keyboard users never fall back to the page.\n  useLayoutEffect(() => {\n    const target = refocus.current;\n    if (!target) return;\n    refocus.current = null;\n    const next =\n      target === \"add\"\n        ? trigger.current\n        : bar.current?.querySelector<HTMLElement>(`[data-reaction=\"${CSS.escape(target)}\"]`);\n    next?.focus();\n  });\n\n  function toggle(id: string) {\n    const option = byId.get(id);\n    if (!option) return;\n    const current = shown.find((reaction) => reaction.id === id);\n    const add = !current?.reacted;\n    const count = Math.max(0, (current?.count ?? 0) + (add ? 1 : -1));\n    const next = current\n      ? list\n          .map((reaction) => (reaction.id === id ? { ...reaction, count, reacted: add } : reaction))\n          .filter((reaction) => reaction.count > 0)\n      : [...list.filter((reaction) => reaction.id !== id), { id, count: 1, reacted: true }];\n    if (count === 0) {\n      const pill = bar.current?.querySelector(`[data-reaction=\"${CSS.escape(id)}\"]`);\n      if (pill && pill === document.activeElement) {\n        const index = shown.findIndex((reaction) => reaction.id === id);\n        refocus.current = shown[index + 1]?.id ?? shown[index - 1]?.id ?? \"add\";\n      }\n    }\n    if (add) setPops((previous) => ({ ...previous, [id]: (previous[id] ?? 0) + 1 }));\n    if (value === undefined) setOwn(next);\n    setAnnouncement(\n      add\n        ? `Added ${option.label}. ${count} ${count === 1 ? \"reaction\" : \"reactions\"}.`\n        : count\n          ? `Removed ${option.label}. ${count} ${count === 1 ? \"reaction\" : \"reactions\"}.`\n          : `Removed ${option.label}.`,\n    );\n    onValueChange?.(next, { id, reacted: add });\n  }\n\n  function pick(id: string) {\n    setOpen(false);\n    trigger.current?.focus();\n    toggle(id);\n  }\n\n  return (\n    <motion.div className={cn(\"relative min-w-0\", className)} style={{ height }}>\n      <div ref={bar} className=\"relative flex flex-wrap items-center gap-x-1.5 gap-y-2\" role=\"group\" aria-label={label}>\n        <LayoutGroup id={groupId}>\n          <AnimatePresence mode=\"popLayout\" initial={false}>\n            {shown.map((reaction) => {\n              const option = byId.get(reaction.id)!;\n              const noun = reaction.count === 1 ? \"reaction\" : \"reactions\";\n              return (\n                <motion.button\n                  key={reaction.id}\n                  layout=\"position\"\n                  type=\"button\"\n                  className={pillClass}\n                  data-reaction={reaction.id}\n                  aria-pressed={reaction.reacted}\n                  aria-label={`${option.label}, ${reaction.count} ${noun}${reaction.reacted ? \", including yours\" : \"\"}`}\n                  onClick={() => toggle(reaction.id)}\n                  initial={reduced ? fadeIn : pillIn}\n                  animate={rest}\n                  exit={reduced ? fadeOut : pillOut}\n                  whileTap={{ scale: reduced ? 1 : 0.97 }}\n                  transition={\n                    reduced ? { duration: motionTokens.duration.fast, layout: { duration: 0 } } : pillTransition\n                  }\n                >\n                  <KickIcon icon={option.icon} pop={pops[reaction.id] ?? 0} reduced={reduced} />\n                  <RollingCount value={reaction.count} reduced={reduced} />\n                  <span className={pillTipClass} aria-hidden=\"true\">\n                    {option.label}\n                  </span>\n                </motion.button>\n              );\n            })}\n          </AnimatePresence>\n          <PopoverPrimitive.Root open={open} onOpenChange={setOpen}>\n            {/* The add button anchors the picker, so it answers a press with colour and never scales. */}\n            <PopoverPrimitive.Trigger asChild>\n              <motion.button\n                ref={trigger}\n                layout=\"position\"\n                transition={reduced ? { layout: { duration: 0 } } : { layout: motionTokens.spring.morph }}\n                type=\"button\"\n                className={addClass}\n                aria-label=\"Add reaction\"\n              >\n                <span className={glyphGridClass} aria-hidden=\"true\">\n                  <AnimatePresence initial={false}>\n                    <motion.span\n                      key={open ? \"close\" : \"add\"}\n                      className=\"grid place-items-center [grid-area:1/1]\"\n                      initial={reduced ? fadeIn : iconIn}\n                      animate={rest}\n                      exit={reduced ? fadeOut : iconOut}\n                      transition={reduced ? { duration: motionTokens.duration.fast } : iconEnter}\n                    >\n                      {open ? <X size={16} strokeWidth={1.75} /> : <SmilePlus size={16} strokeWidth={1.75} />}\n                    </motion.span>\n                  </AnimatePresence>\n                </span>\n                {!open && (\n                  <span className={tipClass} aria-hidden=\"true\">\n                    Add reaction\n                  </span>\n                )}\n              </motion.button>\n            </PopoverPrimitive.Trigger>\n            <AnimatePresence>\n              {open && (\n                <PopoverPrimitive.Portal forceMount>\n                  {/* Focus returns by hand: at once after a pick or Escape, and not at all when people click away. */}\n                  <PopoverPrimitive.Content\n                    forceMount\n                    asChild\n                    side=\"top\"\n                    align=\"center\"\n                    sideOffset={8}\n                    collisionPadding={12}\n                    aria-label=\"Add a reaction\"\n                    onEscapeKeyDown={() => trigger.current?.focus()}\n                    onCloseAutoFocus={(event) => event.preventDefault()}\n                  >\n                    <motion.div\n                      className=\"z-60 grid origin-(--radix-popover-content-transform-origin) gap-0.5 rounded-panel border border-line bg-surface p-[5px] font-body tracking-body text-ink shadow-floating outline-none data-[state=closed]:pointer-events-none\"\n                      initial={reduced ? fadeIn : { opacity: 0, scale: 0.5, filter: blur(motionTokens.blur.soft) }}\n                      animate={rest}\n                      exit={\n                        reduced\n                          ? fadeOut\n                          : {\n                              opacity: 0,\n                              scale: 0.9,\n                              filter: blur(motionTokens.blur.subtle),\n                              transition: { duration: motionTokens.duration.fast, ease: standard },\n                            }\n                      }\n                      transition={\n                        reduced\n                          ? { duration: motionTokens.duration.fast }\n                          : {\n                              scale: motionTokens.spring.snappy,\n                              opacity: { duration: motionTokens.duration.fast, ease: standard },\n                              filter: { duration: motionTokens.duration.fast, ease: enter },\n                            }\n                      }\n                    >\n                      {/* An invisible arrow lets Radix place the transform origin on the trigger's centre, so the tray grows out of the button even when it shifts to stay on screen. */}\n                      <PopoverPrimitive.Arrow width={0} height={0} className=\"invisible\" />\n                      <Picker options={options} reacted={reacted} reduced={reduced} onPick={pick} />\n                    </motion.div>\n                  </PopoverPrimitive.Content>\n                </PopoverPrimitive.Portal>\n              )}\n            </AnimatePresence>\n          </PopoverPrimitive.Root>\n        </LayoutGroup>\n      </div>\n      <output className=\"absolute size-px overflow-hidden whitespace-nowrap [clip-path:inset(50%)]\" aria-live=\"polite\">\n        {announcement}\n      </output>\n    </motion.div>\n  );\n}\n\nexport default Reactions;\n",
      "type": "registry:component",
      "target": "@components/iris/reactions/reactions.tsx"
    }
  ],
  "docs": "Docs and live preview: https://irisui.dev/components/reactions",
  "meta": {
    "kind": "component",
    "group": "Actions",
    "export": "Reactions",
    "tags": [
      "reactions",
      "social",
      "comments"
    ],
    "docs": "https://irisui.dev/components/reactions"
  }
}
