{"$schema":"https://ui.shadcn.com/schema/registry-item.json","author":"Eduardo Calvo <educlopez93@gmail.com>","css":{},"dependencies":["motion","lucide-react"],"description":"A reaction bar with particle bursts, a rolling odometer count, and a hover/focus emoji picker.","devDependencies":[],"files":[{"content":"\"use client\";\n\nimport { cn } from \"@/lib/utils\";\nimport { SmilePlus } from \"lucide-react\";\nimport {\n  AnimatePresence,\n  motion,\n  useAnimate,\n  useReducedMotion,\n} from \"motion/react\";\nimport {\n  type KeyboardEvent,\n  useCallback,\n  useEffect,\n  useId,\n  useMemo,\n  useRef,\n  useState,\n} from \"react\";\n\nexport type EmojiReactionSize = \"sm\" | \"md\" | \"lg\";\n\nexport type EmojiReactionItem = {\n  count?: number;\n  emoji: string;\n  id: string;\n  label: string;\n  reacted?: boolean;\n};\n\nexport type EmojiReactionProps = {\n  /** When false (default), reacting to one item clears any other active reaction. */\n  allowMultiple?: boolean;\n  /** Emits a particle burst when a reaction is selected. */\n  burst?: boolean;\n  burstCount?: number;\n  className?: string;\n  /** Renders the picker popover already open. Useful for docs and screenshots. */\n  defaultPickerOpen?: boolean;\n  onReact?: (id: string, nextReacted: boolean) => void;\n  /** Extra emojis offered by the picker popover. */\n  pickerEmojis?: string[];\n  reactions: EmojiReactionItem[];\n  /** Shows a hover/focus popover with extra reaction options. */\n  showPicker?: boolean;\n  size?: EmojiReactionSize;\n};\n\ntype BurstParticle = {\n  id: string;\n  opacity: number;\n  size: number;\n  x: number;\n  y: number;\n};\n\nconst DEFAULT_BURST_COUNT = 10;\nconst DEFAULT_PICKER_EMOJIS = [\"🎉\", \"🔥\", \"😍\", \"👀\", \"🚀\", \"💯\"];\nconst BURST_ANGLE_JITTER_DEG = 22;\nconst BURST_DISTANCE_MIN_PX = 22;\nconst BURST_DISTANCE_RANGE_PX = 18;\nconst BURST_DURATION_S = 0.55;\nconst BURST_DOT_MIN_PX = 3;\nconst BURST_DOT_RANGE_PX = 3;\nconst BURST_OPACITY_MIN = 0.45;\nconst BURST_OPACITY_RANGE = 0.55;\nconst ODOMETER_OFFSET_PX = 12;\nconst CLOSE_DELAY_MS = 140;\nconst STAGGER_S = 0.03;\nconst PRESS_SCALE = 0.97;\nconst POP_SCALE = 0.7;\nconst POP_OFFSET_PX = 2;\nconst EASE_OUT = [0.23, 1, 0.32, 1] as const;\nconst LCG_MULTIPLIER = 1_664_525;\nconst LCG_INCREMENT = 1_013_904_223;\nconst UINT32_MAX = 0xff_ff_ff_ff;\nconst DEGREES_PER_TURN = 360;\nconst DEGREES_TO_RADIANS = Math.PI / 180;\n\nconst SPRING = { bounce: 0.1, duration: 0.25, type: \"spring\" } as const;\nconst POP_SPRING = { bounce: 0.4, duration: 0.4, type: \"spring\" } as const;\nconst INSTANT = { duration: 0 } as const;\n\nconst SIZE_CLASSES: Record<EmojiReactionSize, string> = {\n  lg: \"h-11 gap-2 px-4 text-base\",\n  md: \"h-9 gap-1.5 px-3 text-sm\",\n  sm: \"h-7 gap-1 px-2 text-xs\",\n};\n\nconst EMOJI_CLASSES: Record<EmojiReactionSize, string> = {\n  lg: \"text-xl\",\n  md: \"text-base\",\n  sm: \"text-sm\",\n};\n\nconst TRIGGER_CLASSES: Record<EmojiReactionSize, string> = {\n  lg: \"size-11\",\n  md: \"size-9\",\n  sm: \"size-7\",\n};\n\nconst MENU_VARIANTS = {\n  closed: {\n    opacity: 0,\n    scale: 0.94,\n    transition: { duration: 0.15, ease: EASE_OUT },\n    y: 6,\n  },\n  open: {\n    opacity: 1,\n    scale: 1,\n    transition: { ...SPRING, staggerChildren: STAGGER_S },\n    y: 0,\n  },\n};\n\nconst MENU_VARIANTS_REDUCED = {\n  closed: { opacity: 0, transition: INSTANT },\n  open: { opacity: 1, transition: INSTANT },\n};\n\nconst MENU_ITEM_VARIANTS = {\n  closed: { opacity: 0, scale: 0.9 },\n  open: { opacity: 1, scale: 1 },\n};\n\nconst MENU_ITEM_VARIANTS_REDUCED = {\n  closed: { opacity: 0 },\n  open: { opacity: 1 },\n};\n\nconst DIGIT_VARIANTS = {\n  center: { opacity: 1, y: 0 },\n  enter: (direction: number) => ({\n    opacity: 0,\n    y: direction * ODOMETER_OFFSET_PX,\n  }),\n  exit: (direction: number) => ({\n    opacity: 0,\n    y: -direction * ODOMETER_OFFSET_PX,\n  }),\n};\n\nconst DIGIT_VARIANTS_REDUCED = {\n  center: { opacity: 1 },\n  enter: { opacity: 0 },\n  exit: { opacity: 0 },\n};\n\n/** Deterministic seeded jitter — no `Math.random()`, called from the click handler. */\nconst createBurstParticles = (count: number, seed: number): BurstParticle[] => {\n  let state = seed;\n  const next = () => {\n    state = (state * LCG_MULTIPLIER + LCG_INCREMENT) >>> 0;\n    return state / UINT32_MAX;\n  };\n  return Array.from({ length: count }, (_, index) => {\n    const baseAngle = (DEGREES_PER_TURN / count) * index;\n    const jitter = (next() - 0.5) * BURST_ANGLE_JITTER_DEG;\n    const angle = (baseAngle + jitter) * DEGREES_TO_RADIANS;\n    const distance = BURST_DISTANCE_MIN_PX + next() * BURST_DISTANCE_RANGE_PX;\n    return {\n      id: `${seed}-${index}`,\n      opacity: BURST_OPACITY_MIN + next() * BURST_OPACITY_RANGE,\n      size: BURST_DOT_MIN_PX + Math.round(next() * BURST_DOT_RANGE_PX),\n      x: Math.cos(angle) * distance,\n      y: Math.sin(angle) * distance,\n    };\n  });\n};\n\nconst useHoverCapable = () => {\n  const [isHoverCapable, setIsHoverCapable] = useState(false);\n\n  useEffect(() => {\n    const mediaQuery = window.matchMedia(\"(hover: hover) and (pointer: fine)\");\n    setIsHoverCapable(mediaQuery.matches);\n    const handleChange = (event: MediaQueryListEvent) => {\n      setIsHoverCapable(event.matches);\n    };\n    mediaQuery.addEventListener(\"change\", handleChange);\n    return () => mediaQuery.removeEventListener(\"change\", handleChange);\n  }, []);\n\n  return isHoverCapable;\n};\n\n/**\n * One burst = one ring plus a handful of dots, all keyed to the same seed.\n * The ring owns the lifecycle: when its animation completes the whole layer\n * is unmounted, so particles can never leak across reactions.\n */\nconst BurstLayer = ({\n  count,\n  onComplete,\n  seed,\n}: {\n  count: number;\n  onComplete: () => void;\n  seed: number;\n}) => {\n  const particles = useMemo(\n    () => createBurstParticles(count, seed),\n    [count, seed]\n  );\n\n  return (\n    <span\n      aria-hidden=\"true\"\n      className=\"pointer-events-none absolute inset-0 flex items-center justify-center\"\n    >\n      <motion.span\n        animate={{ opacity: 0, scale: 1.9 }}\n        className=\"absolute size-6 rounded-full border border-brand/50\"\n        initial={{ opacity: 0.7, scale: 0.4 }}\n        onAnimationComplete={onComplete}\n        transition={{ duration: BURST_DURATION_S, ease: EASE_OUT }}\n      />\n      {particles.map((particle) => (\n        <motion.span\n          animate={{ opacity: 0, scale: 0.4, x: particle.x, y: particle.y }}\n          className=\"absolute rounded-full bg-brand\"\n          initial={{ opacity: particle.opacity, scale: 1, x: 0, y: 0 }}\n          key={particle.id}\n          style={{ height: particle.size, width: particle.size }}\n          transition={{ duration: BURST_DURATION_S, ease: EASE_OUT }}\n        />\n      ))}\n    </span>\n  );\n};\n\nconst OdometerCount = ({\n  count,\n  shouldReduceMotion,\n}: {\n  count: number;\n  shouldReduceMotion: boolean;\n}) => {\n  const previousRef = useRef(count);\n  const direction = count < previousRef.current ? -1 : 1;\n\n  useEffect(() => {\n    previousRef.current = count;\n  }, [count]);\n\n  return (\n    <span\n      aria-hidden=\"true\"\n      className=\"relative inline-flex h-[1.15em] items-center overflow-hidden text-center tabular-nums leading-none\"\n    >\n      <AnimatePresence custom={direction} initial={false} mode=\"popLayout\">\n        <motion.span\n          animate=\"center\"\n          className=\"block leading-none\"\n          custom={direction}\n          exit=\"exit\"\n          initial=\"enter\"\n          key={count}\n          transition={shouldReduceMotion ? INSTANT : SPRING}\n          variants={\n            shouldReduceMotion ? DIGIT_VARIANTS_REDUCED : DIGIT_VARIANTS\n          }\n        >\n          {count}\n        </motion.span>\n      </AnimatePresence>\n    </span>\n  );\n};\n\ntype ReactionButtonProps = {\n  burst: boolean;\n  burstCount: number;\n  onSelect: (id: string) => void;\n  reaction: EmojiReactionItem;\n  shouldReduceMotion: boolean;\n  size: EmojiReactionSize;\n};\n\nconst ReactionButton = ({\n  burst,\n  burstCount,\n  onSelect,\n  reaction,\n  shouldReduceMotion,\n  size,\n}: ReactionButtonProps) => {\n  const [burstId, setBurstId] = useState<number | null>(null);\n  const [emojiScope, animateEmoji] = useAnimate<HTMLSpanElement>();\n  const seedRef = useRef(0);\n  const count = reaction.count ?? 0;\n  const reacted = Boolean(reaction.reacted);\n\n  const handleClick = () => {\n    const nextReacted = !reacted;\n    onSelect(reaction.id);\n    if (!nextReacted) {\n      return;\n    }\n    // The pop is driven imperatively so a press animates the one emoji\n    // element that already exists. Remounting it on every press (via a\n    // changing `key`) left the previous node stranded in the DOM.\n    if (!shouldReduceMotion && emojiScope.current) {\n      animateEmoji(\n        emojiScope.current,\n        { scale: [POP_SCALE, 1], y: [POP_OFFSET_PX, 0] },\n        POP_SPRING\n      );\n    }\n    if (burst && !shouldReduceMotion) {\n      seedRef.current += 1;\n      setBurstId(seedRef.current);\n    }\n  };\n\n  const handleBurstComplete = useCallback(() => {\n    setBurstId(null);\n  }, []);\n\n  return (\n    <motion.button\n      aria-label={`${reaction.label}, ${count} ${count === 1 ? \"reaction\" : \"reactions\"}`}\n      aria-pressed={reacted}\n      className={cn(\n        \"relative inline-flex cursor-pointer select-none items-center rounded-full border font-medium\",\n        \"transition-[background-color,border-color,box-shadow,color] duration-150 ease-out\",\n        \"outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background\",\n        \"before:absolute before:-inset-1 before:content-['']\",\n        SIZE_CLASSES[size],\n        // A pill holding a saturated emoji must not be painted: brand fill\n        // fights the glyph it contains. The toggle reads physically instead —\n        // resting pills sit a hair proud of the page, a reacted pill is\n        // pressed into a well with a crisper rim and full-strength label.\n        // Brand shows up only in the burst, at the moment of the press.\n        reacted\n          ? \"border-foreground/20 bg-foreground/[0.06] text-foreground shadow-[inset_0_1px_2px_rgb(0_0_0/0.10)] dark:border-foreground/25 dark:bg-foreground/[0.10] dark:shadow-[inset_0_1px_2px_rgb(0_0_0/0.45)]\"\n          : \"border-foreground/12 bg-background text-muted-foreground shadow-[0_1px_1px_rgb(0_0_0/0.04)] hover:border-foreground/25 hover:bg-foreground/[0.03] hover:text-foreground dark:shadow-none\"\n      )}\n      onClick={handleClick}\n      type=\"button\"\n      whileTap={shouldReduceMotion ? undefined : { scale: PRESS_SCALE }}\n    >\n      <span className=\"relative inline-flex items-center justify-center\">\n        <span\n          aria-hidden=\"true\"\n          className={cn(\"block leading-none\", EMOJI_CLASSES[size])}\n          ref={emojiScope}\n        >\n          {reaction.emoji}\n        </span>\n        {burstId === null ? null : (\n          <BurstLayer\n            count={burstCount}\n            key={`burst-${burstId}`}\n            onComplete={handleBurstComplete}\n            seed={burstId}\n          />\n        )}\n      </span>\n      <OdometerCount count={count} shouldReduceMotion={shouldReduceMotion} />\n    </motion.button>\n  );\n};\n\ntype EmojiPickerProps = {\n  defaultOpen: boolean;\n  emojis: string[];\n  onSelect: (emoji: string) => void;\n  shouldReduceMotion: boolean;\n  size: EmojiReactionSize;\n};\n\nconst EmojiPicker = ({\n  defaultOpen,\n  emojis,\n  onSelect,\n  shouldReduceMotion,\n  size,\n}: EmojiPickerProps) => {\n  const isHoverCapable = useHoverCapable();\n  const [isOpen, setIsOpen] = useState(defaultOpen);\n  const [activeIndex, setActiveIndex] = useState(0);\n  const containerRef = useRef<HTMLDivElement>(null);\n  const triggerRef = useRef<HTMLButtonElement>(null);\n  const itemRefs = useRef<(HTMLButtonElement | null)[]>([]);\n  const closeTimerRef = useRef<number | null>(null);\n  const shouldFocusRef = useRef(false);\n  const menuId = useId();\n\n  const clearCloseTimer = useCallback(() => {\n    if (closeTimerRef.current !== null) {\n      window.clearTimeout(closeTimerRef.current);\n      closeTimerRef.current = null;\n    }\n  }, []);\n\n  const open = useCallback(\n    (withFocus: boolean) => {\n      clearCloseTimer();\n      shouldFocusRef.current = withFocus;\n      setIsOpen(true);\n    },\n    [clearCloseTimer]\n  );\n\n  const close = useCallback(\n    (returnFocus: boolean) => {\n      clearCloseTimer();\n      setIsOpen(false);\n      if (returnFocus) {\n        triggerRef.current?.focus();\n      }\n    },\n    [clearCloseTimer]\n  );\n\n  // A delayed close keeps the popover alive while the pointer crosses the\n  // gap between the trigger and the card, which is what causes flicker.\n  const scheduleClose = useCallback(() => {\n    clearCloseTimer();\n    closeTimerRef.current = window.setTimeout(() => {\n      setIsOpen(false);\n    }, CLOSE_DELAY_MS);\n  }, [clearCloseTimer]);\n\n  useEffect(() => clearCloseTimer, [clearCloseTimer]);\n\n  useEffect(() => {\n    if (!isOpen) {\n      return;\n    }\n    const handlePointerDown = (event: PointerEvent) => {\n      if (!containerRef.current?.contains(event.target as Node)) {\n        close(false);\n      }\n    };\n    const handleKeyDown = (event: globalThis.KeyboardEvent) => {\n      if (event.key === \"Escape\") {\n        close(true);\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  }, [close, isOpen]);\n\n  // Only a keyboard or click open moves focus — hovering must never steal it.\n  useEffect(() => {\n    if (isOpen && shouldFocusRef.current) {\n      shouldFocusRef.current = false;\n      setActiveIndex(0);\n      itemRefs.current[0]?.focus();\n    }\n  }, [isOpen]);\n\n  const handleTriggerKeyDown = (event: KeyboardEvent<HTMLButtonElement>) => {\n    if (event.key === \"ArrowDown\" || event.key === \"ArrowUp\") {\n      event.preventDefault();\n      open(true);\n    }\n  };\n\n  const focusItem = (index: number) => {\n    setActiveIndex(index);\n    itemRefs.current[index]?.focus();\n  };\n\n  const handleItemKeyDown = (\n    event: KeyboardEvent<HTMLButtonElement>,\n    index: number\n  ) => {\n    if (event.key === \"ArrowRight\" || event.key === \"ArrowDown\") {\n      event.preventDefault();\n      focusItem((index + 1) % emojis.length);\n      return;\n    }\n    if (event.key === \"ArrowLeft\" || event.key === \"ArrowUp\") {\n      event.preventDefault();\n      focusItem((index - 1 + emojis.length) % emojis.length);\n      return;\n    }\n    if (event.key === \"Escape\") {\n      close(true);\n      return;\n    }\n    if (event.key === \"Tab\") {\n      close(false);\n    }\n  };\n\n  return (\n    <div\n      className=\"relative inline-flex\"\n      onMouseEnter={() => {\n        if (isHoverCapable) {\n          open(false);\n        }\n      }}\n      onMouseLeave={() => {\n        if (isHoverCapable) {\n          scheduleClose();\n        }\n      }}\n      ref={containerRef}\n    >\n      <motion.button\n        aria-controls={menuId}\n        aria-expanded={isOpen}\n        aria-haspopup=\"menu\"\n        aria-label=\"More reactions\"\n        className={cn(\n          \"inline-flex cursor-pointer items-center justify-center rounded-full border border-foreground/12 bg-background text-muted-foreground\",\n          \"transition-[background-color,border-color,color] duration-150 ease-out hover:border-foreground/25 hover:bg-foreground/5 hover:text-foreground\",\n          \"outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background\",\n          TRIGGER_CLASSES[size]\n        )}\n        onClick={() => (isOpen ? close(false) : open(true))}\n        onKeyDown={handleTriggerKeyDown}\n        ref={triggerRef}\n        type=\"button\"\n        whileTap={shouldReduceMotion ? undefined : { scale: PRESS_SCALE }}\n      >\n        <SmilePlus aria-hidden=\"true\" className=\"size-4\" />\n      </motion.button>\n\n      <AnimatePresence>\n        {isOpen ? (\n          // The wrapper carries the offset as padding, not margin, so the\n          // pointer never leaves the hover region on its way to the card.\n          <span\n            className=\"absolute bottom-full left-1/2 z-20 block -translate-x-1/2 pb-2\"\n            key=\"picker\"\n          >\n            <motion.div\n              animate=\"open\"\n              className=\"flex gap-1 rounded-2xl border border-foreground/10 bg-background p-1.5 shadow-black/5 shadow-lg\"\n              exit=\"closed\"\n              id={menuId}\n              initial=\"closed\"\n              role=\"menu\"\n              style={{ transformOrigin: \"bottom center\" }}\n              variants={\n                shouldReduceMotion ? MENU_VARIANTS_REDUCED : MENU_VARIANTS\n              }\n            >\n              {emojis.map((emoji, index) => (\n                <motion.button\n                  aria-label={`React with ${emoji}`}\n                  className=\"flex size-9 cursor-pointer items-center justify-center rounded-xl text-lg outline-none transition-colors duration-150 ease-out hover:bg-foreground/10 focus-visible:ring-2 focus-visible:ring-ring\"\n                  key={emoji}\n                  onClick={() => {\n                    onSelect(emoji);\n                    close(true);\n                  }}\n                  onKeyDown={(event) => handleItemKeyDown(event, index)}\n                  ref={(el) => {\n                    itemRefs.current[index] = el;\n                  }}\n                  role=\"menuitem\"\n                  tabIndex={activeIndex === index ? 0 : -1}\n                  type=\"button\"\n                  variants={\n                    shouldReduceMotion\n                      ? MENU_ITEM_VARIANTS_REDUCED\n                      : MENU_ITEM_VARIANTS\n                  }\n                >\n                  {emoji}\n                </motion.button>\n              ))}\n            </motion.div>\n          </span>\n        ) : null}\n      </AnimatePresence>\n    </div>\n  );\n};\n\nconst EmojiReaction = ({\n  allowMultiple = false,\n  burst = true,\n  burstCount = DEFAULT_BURST_COUNT,\n  className,\n  defaultPickerOpen = false,\n  onReact,\n  pickerEmojis = DEFAULT_PICKER_EMOJIS,\n  reactions,\n  showPicker = false,\n  size = \"md\",\n}: EmojiReactionProps) => {\n  const shouldReduceMotion = Boolean(useReducedMotion());\n\n  const handleReact = useCallback(\n    (id: string) => {\n      const target = reactions.find((reaction) => reaction.id === id);\n      if (!target) {\n        return;\n      }\n      const nextReacted = !target.reacted;\n      if (!allowMultiple && nextReacted) {\n        for (const other of reactions) {\n          if (other.id !== id && other.reacted) {\n            onReact?.(other.id, false);\n          }\n        }\n      }\n      onReact?.(id, nextReacted);\n    },\n    [allowMultiple, onReact, reactions]\n  );\n\n  // A picked emoji that already exists in the bar toggles that reaction so\n  // the exclusivity rule still applies; anything else is reported as new.\n  const handlePickerSelect = useCallback(\n    (emoji: string) => {\n      const existing = reactions.find(\n        (reaction) => reaction.id === emoji || reaction.emoji === emoji\n      );\n      if (existing) {\n        if (!existing.reacted) {\n          handleReact(existing.id);\n        }\n        return;\n      }\n      onReact?.(emoji, true);\n    },\n    [handleReact, onReact, reactions]\n  );\n\n  return (\n    <div\n      aria-label=\"Reactions\"\n      className={cn(\"flex flex-wrap items-center gap-2\", className)}\n      role=\"group\"\n    >\n      {reactions.map((reaction) => (\n        <ReactionButton\n          burst={burst}\n          burstCount={burstCount}\n          key={reaction.id}\n          onSelect={handleReact}\n          reaction={reaction}\n          shouldReduceMotion={shouldReduceMotion}\n          size={size}\n        />\n      ))}\n      {showPicker ? (\n        <EmojiPicker\n          defaultOpen={defaultPickerOpen}\n          emojis={pickerEmojis}\n          onSelect={handlePickerSelect}\n          shouldReduceMotion={shouldReduceMotion}\n          size={size}\n        />\n      ) : null}\n    </div>\n  );\n};\n\nexport default EmojiReaction;\n","path":"index.tsx","target":"components/smoothui/emoji-reaction/index.tsx","type":"registry:ui"}],"name":"emoji-reaction","registryDependencies":["https://smoothui.dev/r/tokens.json"],"title":"Emoji Reaction","type":"registry:ui"}