{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "reaction-bar",
  "title": "Reaction Bar",
  "description": "A Slack-style emoji reaction row with popping chips, direction-aware rolling counts, and a staggered emoji-picker popover",
  "dependencies": [
    "framer-motion",
    "lucide-react"
  ],
  "files": [
    {
      "path": "app/registry/reaction-bar/reaction-bar.tsx",
      "content": "/**\n * Spectrum UI — ReactionBar\n *\n * A Slack-style emoji reaction row. Chips toggle your reaction with a quick\n * emoji pop and a direction-aware rolling count; hovering a chip springs the\n * emoji up, and a \"+\" button opens a popover strip — scaling out of its\n * trigger — where emojis stagger in left-to-right so new reactions can be\n * added. Escape or an outside click closes the picker and returns focus to\n * the trigger. Works controlled or uncontrolled, honors\n * prefers-reduced-motion, and exposes chip state via aria-pressed.\n *\n * Dependencies: framer-motion, lucide-react, @/lib/utils\n *\n * @example\n * <ReactionBar\n *   defaultReactions={[{ emoji: \"👍\", count: 3 }]}\n *   onReactionsChange={(reactions) => save(reactions)}\n * />\n */\n\n\"use client\"\n\nimport React, { useCallback, useEffect, useId, useRef, useState } from \"react\"\nimport { AnimatePresence, motion, useReducedMotion } from \"framer-motion\"\nimport { Plus } from \"lucide-react\"\nimport { cn } from \"@/lib/utils\"\n\n// ─── Types ───────────────────────────────────────────────────────────────────\n\nexport interface Reaction {\n  /** The emoji character rendered on the chip */\n  emoji: string\n  /** Total number of reactions for this emoji, including yours */\n  count: number\n  /** Whether the current user has reacted with this emoji */\n  reacted?: boolean\n}\n\nexport interface ReactionBarProps {\n  /** Controlled reactions. Leave undefined for uncontrolled usage */\n  reactions?: Reaction[]\n  /** Initial reactions when uncontrolled. Default [] */\n  defaultReactions?: Reaction[]\n  /** Fires with the next reactions array on every change */\n  onReactionsChange?: (reactions: Reaction[]) => void\n  /** Emojis offered in the add popover */\n  availableEmojis?: string[]\n  /** Show the \"+\" add-reaction button. Default true */\n  addable?: boolean\n  /** Visual size of the chips. Default \"md\" */\n  size?: \"sm\" | \"md\"\n  className?: string\n}\n\n// ─── Constants ───────────────────────────────────────────────────────────────\n\nconst DEFAULT_EMOJIS = [\"👍\", \"❤️\", \"😂\", \"🎉\", \"😮\", \"🚀\"]\n\n/** Snappy spring for chip enter/exit and other micro state changes */\nconst SPRING_SNAPPY = { type: \"spring\", stiffness: 500, damping: 30 } as const\n\n/** Softer spring for the picker surface scaling out of its anchor */\nconst SPRING_SURFACE = { type: \"spring\", stiffness: 260, damping: 22 } as const\n\n/** Spring for the emoji hover lift and the picker options popping in */\nconst SPRING_POP = { type: \"spring\", stiffness: 400, damping: 18 } as const\n\n/** Left-to-right stagger between picker emojis, in seconds */\nconst PICKER_STAGGER = 0.03\n\n/** Exit for the picker: quicker than the entrance so dismissal feels light */\nconst PICKER_EXIT_TRANSITION = {\n  duration: 0.15,\n  ease: [0.22, 1, 0.36, 1],\n} as const\n\n/** How far the rolling count travels, in px */\nconst COUNT_ROLL_DISTANCE = 10\n\nconst SIZES = {\n  sm: { chip: \"h-6 gap-1 px-2 text-xs\", emoji: \"text-xs\", add: \"h-6 w-6\", icon: 12 },\n  md: { chip: \"h-7 gap-1.5 px-2.5 text-xs\", emoji: \"text-sm\", add: \"h-7 w-7\", icon: 14 },\n} as const\n\nconst countVariants = {\n  enter: (direction: number) => ({ y: direction * COUNT_ROLL_DISTANCE, opacity: 0 }),\n  center: { y: 0, opacity: 1 },\n  exit: (direction: number) => ({ y: direction * -COUNT_ROLL_DISTANCE, opacity: 0 }),\n}\n\n// ─── Component ───────────────────────────────────────────────────────────────\n\nexport function ReactionBar({\n  reactions: reactionsProp,\n  defaultReactions = [],\n  onReactionsChange,\n  availableEmojis = DEFAULT_EMOJIS,\n  addable = true,\n  size = \"md\",\n  className,\n}: ReactionBarProps) {\n  const shouldReduceMotion = useReducedMotion()\n  const [internalReactions, setInternalReactions] = useState(defaultReactions)\n  const [open, setOpen] = useState(false)\n  // Wraps the \"+\" trigger and its popover so outside-click can ignore both\n  const anchorRef = useRef<HTMLSpanElement>(null)\n  const addButtonRef = useRef<HTMLButtonElement>(null)\n  const popoverId = useId()\n\n  const reactions = reactionsProp ?? internalReactions\n  const { chip, emoji: emojiSize, add, icon } = SIZES[size]\n\n  const commitReactions = useCallback(\n    (next: Reaction[]) => {\n      if (reactionsProp === undefined) setInternalReactions(next)\n      onReactionsChange?.(next)\n    },\n    [reactionsProp, onReactionsChange],\n  )\n\n  const toggleReaction = useCallback(\n    (emoji: string) => {\n      const next = reactions\n        .map((reaction) =>\n          reaction.emoji === emoji\n            ? {\n                ...reaction,\n                count: reaction.count + (reaction.reacted ? -1 : 1),\n                reacted: !reaction.reacted,\n              }\n            : reaction,\n        )\n        .filter((reaction) => reaction.count > 0)\n      commitReactions(next)\n    },\n    [reactions, commitReactions],\n  )\n\n  const addReaction = useCallback(\n    (emoji: string) => {\n      const existing = reactions.find((reaction) => reaction.emoji === emoji)\n      if (!existing) {\n        commitReactions([...reactions, { emoji, count: 1, reacted: true }])\n      } else if (!existing.reacted) {\n        commitReactions(\n          reactions.map((reaction) =>\n            reaction.emoji === emoji\n              ? { ...reaction, count: reaction.count + 1, reacted: true }\n              : reaction,\n          ),\n        )\n      }\n      setOpen(false)\n      // Hand focus back to the trigger so keyboard flow continues from the \"+\"\n      addButtonRef.current?.focus()\n    },\n    [reactions, commitReactions],\n  )\n\n  // Close the popover on outside click and Escape; both listeners are scoped\n  // to the open state so they never outlive the popover or the component.\n  useEffect(() => {\n    if (!open) return\n    const handlePointerDown = (event: PointerEvent) => {\n      // Ignore the trigger itself — its own onClick handles the toggle\n      if (!anchorRef.current?.contains(event.target as Node)) setOpen(false)\n    }\n    const handleKeyDown = (event: KeyboardEvent) => {\n      if (event.key === \"Escape\") {\n        setOpen(false)\n        addButtonRef.current?.focus()\n      }\n    }\n    document.addEventListener(\"pointerdown\", handlePointerDown)\n    document.addEventListener(\"keydown\", handleKeyDown)\n    return () => {\n      document.removeEventListener(\"pointerdown\", handlePointerDown)\n      document.removeEventListener(\"keydown\", handleKeyDown)\n    }\n  }, [open])\n\n  const chipTransition = shouldReduceMotion ? { duration: 0 } : SPRING_SNAPPY\n\n  return (\n    <div\n      className={cn(\"relative inline-flex flex-wrap items-center gap-1.5\", className)}\n    >\n      <AnimatePresence mode=\"popLayout\" initial={false}>\n        {reactions.map((reaction) => {\n          const reacted = Boolean(reaction.reacted)\n          // Reacted means the count just went up, so digits roll upward\n          const direction = reacted ? 1 : -1\n          return (\n            <motion.button\n              key={reaction.emoji}\n              type=\"button\"\n              layout\n              initial={{ scale: 0.5, opacity: 0 }}\n              animate={{ scale: 1, opacity: 1 }}\n              exit={{ scale: 0.5, opacity: 0 }}\n              transition={chipTransition}\n              whileHover={shouldReduceMotion ? undefined : \"hover\"}\n              whileTap={shouldReduceMotion ? undefined : { scale: 0.95 }}\n              onClick={() => toggleReaction(reaction.emoji)}\n              aria-pressed={reacted}\n              aria-label={`React with ${reaction.emoji}, ${reaction.count} ${\n                reaction.count === 1 ? \"reaction\" : \"reactions\"\n              }`}\n              className={cn(\n                \"inline-flex select-none touch-manipulation items-center rounded-full border font-medium transition-colors\",\n                \"focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-neutral-950 dark:focus-visible:ring-neutral-300\",\n                reacted\n                  ? \"border-sky-200 bg-sky-50 text-sky-700 dark:border-sky-500/30 dark:bg-sky-500/10 dark:text-sky-400\"\n                  : \"border-neutral-200 bg-white text-neutral-600 hover:border-neutral-300 dark:border-neutral-800 dark:bg-neutral-900 dark:text-neutral-400 dark:hover:border-neutral-700\",\n                chip,\n              )}\n            >\n              {/* Transform-only lift so the hover never shifts the chip's layout */}\n              <motion.span\n                className={cn(\"inline-flex leading-none\", emojiSize)}\n                variants={{ hover: { scale: 1.25, y: -1 } }}\n                transition={shouldReduceMotion ? { duration: 0 } : SPRING_POP}\n              >\n                <motion.span\n                  className=\"inline-block\"\n                  initial={false}\n                  animate={\n                    reacted && !shouldReduceMotion\n                      ? { scale: [1, 1.4, 1] }\n                      : { scale: 1 }\n                  }\n                  transition={{ duration: 0.35, ease: \"easeOut\" }}\n                >\n                  {reaction.emoji}\n                </motion.span>\n              </motion.span>\n\n              {/* ch-based min-width keeps the chip steady while digits roll */}\n              <span\n                className=\"relative inline-flex justify-center overflow-hidden tabular-nums\"\n                style={{ minWidth: `${String(reaction.count).length}ch` }}\n                aria-hidden=\"true\"\n              >\n                <AnimatePresence mode=\"popLayout\" initial={false} custom={direction}>\n                  <motion.span\n                    key={reaction.count}\n                    className=\"inline-block\"\n                    custom={direction}\n                    variants={countVariants}\n                    initial=\"enter\"\n                    animate=\"center\"\n                    exit=\"exit\"\n                    transition={\n                      shouldReduceMotion ? { duration: 0 } : SPRING_SNAPPY\n                    }\n                  >\n                    {reaction.count}\n                  </motion.span>\n                </AnimatePresence>\n              </span>\n            </motion.button>\n          )\n        })}\n      </AnimatePresence>\n\n      {addable && (\n        <span ref={anchorRef} className=\"relative inline-flex\">\n          <motion.button\n            ref={addButtonRef}\n            type=\"button\"\n            onClick={() => setOpen((prev) => !prev)}\n            aria-expanded={open}\n            aria-haspopup=\"true\"\n            aria-controls={open ? popoverId : undefined}\n            aria-label=\"Add reaction\"\n            whileTap={shouldReduceMotion ? undefined : { scale: 0.92 }}\n            className={cn(\n              \"inline-flex touch-manipulation items-center justify-center rounded-full border border-neutral-200 bg-white text-neutral-500 transition-colors\",\n              \"hover:border-neutral-300 hover:bg-neutral-50 hover:text-neutral-700 dark:border-neutral-800 dark:bg-neutral-900 dark:text-neutral-400 dark:hover:border-neutral-700 dark:hover:bg-neutral-800 dark:hover:text-neutral-200\",\n              \"focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-neutral-950 dark:focus-visible:ring-neutral-300\",\n              add,\n            )}\n          >\n            <motion.span\n              className=\"inline-flex\"\n              initial={false}\n              animate={{ rotate: open ? 45 : 0 }}\n              transition={shouldReduceMotion ? { duration: 0 } : SPRING_SNAPPY}\n            >\n              <Plus size={icon} aria-hidden=\"true\" />\n            </motion.span>\n          </motion.button>\n\n          <AnimatePresence>\n            {open && (\n              <motion.div\n                id={popoverId}\n                role=\"group\"\n                aria-label=\"Pick a reaction\"\n                initial={{ opacity: 0, y: 4, scale: 0.9 }}\n                animate={{ opacity: 1, y: 0, scale: 1 }}\n                exit={{\n                  opacity: 0,\n                  y: 4,\n                  scale: 0.9,\n                  transition: shouldReduceMotion\n                    ? { duration: 0 }\n                    : PICKER_EXIT_TRANSITION,\n                }}\n                transition={\n                  shouldReduceMotion ? { duration: 0 } : SPRING_SURFACE\n                }\n                style={{\n                  // Scale out of the \"+\" trigger, which sits at the bottom-left\n                  transformOrigin: \"bottom left\",\n                }}\n                className={cn(\n                  \"absolute bottom-full left-0 z-10 mb-2 flex items-center gap-0.5 rounded-full border border-transparent bg-white px-1.5 py-1\",\n                  \"shadow-[0px_0px_0px_1px_rgba(0,0,0,0.06),0px_1px_2px_0px_rgba(0,0,0,0.04),0px_2px_4px_0px_rgba(0,0,0,0.04)]\",\n                  \"dark:border-neutral-800 dark:bg-neutral-900 dark:shadow-none\",\n                )}\n              >\n                {availableEmojis.map((emoji, index) => (\n                  <motion.button\n                    key={emoji}\n                    type=\"button\"\n                    initial={shouldReduceMotion ? false : { scale: 0, y: 4 }}\n                    animate={{ scale: 1, y: 0 }}\n                    transition={\n                      shouldReduceMotion\n                        ? { duration: 0 }\n                        : // Stagger reads left-to-right, away from the trigger\n                          { ...SPRING_POP, delay: index * PICKER_STAGGER }\n                    }\n                    whileHover={\n                      shouldReduceMotion ? undefined : { scale: 1.25, y: -1 }\n                    }\n                    whileTap={shouldReduceMotion ? undefined : { scale: 0.9 }}\n                    onClick={() => addReaction(emoji)}\n                    aria-label={`React with ${emoji}`}\n                    className=\"flex h-7 w-7 touch-manipulation select-none items-center justify-center rounded-full text-sm leading-none transition-colors hover:bg-neutral-100 active:bg-neutral-200 focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-neutral-950 dark:hover:bg-neutral-800 dark:active:bg-neutral-700 dark:focus-visible:ring-neutral-300\"\n                  >\n                    {emoji}\n                  </motion.button>\n                ))}\n              </motion.div>\n            )}\n          </AnimatePresence>\n        </span>\n      )}\n    </div>\n  )\n}\n",
      "type": "registry:component",
      "target": "components/spectrumui/reaction-bar.tsx"
    }
  ],
  "type": "registry:component"
}
