{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "reaction-picker",
  "type": "registry:component",
  "title": "Reaction Picker",
  "description": "Tap or hold the smiley and a tray of emoji pours out of it like liquid; slide to one and let go, and it lands on the message as a counted chip with yours lit up.",
  "categories": [
    "component",
    "Together"
  ],
  "dependencies": [
    "lucide-react",
    "motion"
  ],
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "registry/devl/reaction-picker.tsx",
      "type": "registry:component",
      "content": "\"use client\";\n\nimport { SmilePlusIcon } from \"lucide-react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { type KeyboardEvent, type PointerEvent, useEffect, useId, useRef, useState } from \"react\";\nimport { cn } from \"@/lib/utils\";\n\n// Stays false until mounted so the server render and the first client render match.\nfunction useSafeReducedMotion() {\n  const prefers = useReducedMotion();\n  const [mounted, setMounted] = useState(false);\n  useEffect(() => setMounted(true), []);\n  return mounted && !!prefers;\n}\n\nexport interface ReactionOption {\n  emoji: string;\n  label: string;\n}\n\nexport interface Reaction {\n  emoji: string;\n  count: number;\n  mine?: boolean;\n}\n\nexport interface ReactionPickerProps {\n  reactions?: Reaction[];\n  options?: ReactionOption[];\n  onChange?: (reactions: Reaction[]) => void;\n  holdDelay?: number;\n  className?: string;\n}\n\nexport const defaultReactionOptions: ReactionOption[] = [\n  { emoji: \"👍\", label: \"Like\" },\n  { emoji: \"❤️\", label: \"Love\" },\n  { emoji: \"😂\", label: \"Funny\" },\n  { emoji: \"🎉\", label: \"Celebrate\" },\n  { emoji: \"😮\", label: \"Wow\" },\n  { emoji: \"🙏\", label: \"Thanks\" },\n];\n\nconst TRIGGER = 32;\nconst SLOT = 36;\nconst PAD = 4;\nconst TRAY_H = 44;\nconst GAP = 8;\nconst BLUR_ROOM = 24;\n\nconst spring = { type: \"spring\", stiffness: 520, damping: 34 } as const;\n\nfunction Count({ value, reduce }: { value: number; reduce: boolean | null }) {\n  return (\n    <span className=\"relative inline-flex h-4 min-w-[1ch] justify-center overflow-hidden tabular-nums\">\n      <AnimatePresence initial={false} mode=\"popLayout\">\n        <motion.span\n          animate={{ y: 0, opacity: 1 }}\n          exit={reduce ? { opacity: 0 } : { y: -10, opacity: 0 }}\n          initial={reduce ? { opacity: 0 } : { y: 10, opacity: 0 }}\n          key={value}\n          transition={{ duration: 0.18, ease: [0.23, 1, 0.32, 1] }}\n        >\n          {value}\n        </motion.span>\n      </AnimatePresence>\n    </span>\n  );\n}\n\nexport function ReactionPicker({\n  reactions: initial = [],\n  options = defaultReactionOptions,\n  onChange,\n  holdDelay = 280,\n  className,\n}: ReactionPickerProps) {\n  const reduce = useSafeReducedMotion();\n  const filterId = `goo-${useId().replace(/:/g, \"\")}`;\n  const trayId = useId();\n  const [reactions, setReactions] = useState(initial);\n  const [open, setOpen] = useState(false);\n  const [hovered, setHovered] = useState<number | null>(null);\n  const [focusIndex, setFocusIndex] = useState(0);\n  const holdTimer = useRef<ReturnType<typeof setTimeout>>(undefined);\n  const holding = useRef(false);\n  const wasOpen = useRef(false);\n  const triggerRef = useRef<HTMLButtonElement>(null);\n  const emojiRefs = useRef<(HTMLButtonElement | null)[]>([]);\n  const trayW = options.length * SLOT + PAD * 2;\n\n  useEffect(() => () => clearTimeout(holdTimer.current), []);\n\n  const toggle = (emoji: string) => {\n    setReactions((rs) => {\n      const existing = rs.find((r) => r.emoji === emoji);\n      let next: Reaction[];\n      if (!existing) next = [...rs, { emoji, count: 1, mine: true }];\n      else if (existing.mine) {\n        next = rs\n          .map((r) => (r.emoji === emoji ? { ...r, count: r.count - 1, mine: false } : r))\n          .filter((r) => r.count > 0);\n      } else next = rs.map((r) => (r.emoji === emoji ? { ...r, count: r.count + 1, mine: true } : r));\n      onChange?.(next);\n      return next;\n    });\n  };\n\n  const pick = (emoji: string) => {\n    const mine = reactions.find((r) => r.emoji === emoji)?.mine;\n    if (!mine) toggle(emoji);\n    close(true);\n  };\n\n  const close = (refocus = false) => {\n    setOpen(false);\n    setHovered(null);\n    if (refocus) triggerRef.current?.focus({ preventScroll: true });\n  };\n\n  const emojiAt = (x: number, y: number) => {\n    const i = emojiRefs.current.findIndex((el) => {\n      if (!el) return false;\n      const r = el.getBoundingClientRect();\n      return x >= r.left - 2 && x <= r.right + 2 && y >= r.top - 14 && y <= r.bottom + 14;\n    });\n    return i < 0 ? null : i;\n  };\n\n  const onPointerDown = (e: PointerEvent<HTMLButtonElement>) => {\n    if (e.button !== 0) return;\n    e.currentTarget.setPointerCapture(e.pointerId);\n    wasOpen.current = open;\n    holding.current = false;\n    clearTimeout(holdTimer.current);\n    holdTimer.current = setTimeout(() => {\n      holding.current = true;\n      setOpen(true);\n    }, holdDelay);\n  };\n  const onPointerMove = (e: PointerEvent<HTMLButtonElement>) => {\n    if (holding.current) setHovered(emojiAt(e.clientX, e.clientY));\n  };\n  const onPointerUp = (e: PointerEvent<HTMLButtonElement>) => {\n    clearTimeout(holdTimer.current);\n    if (holding.current) {\n      holding.current = false;\n      const i = emojiAt(e.clientX, e.clientY);\n      if (i !== null) pick(options[i].emoji);\n      return;\n    }\n    setOpen(!wasOpen.current);\n  };\n\n  const onTriggerKey = (e: KeyboardEvent<HTMLButtonElement>) => {\n    if (e.key === \"Enter\" || e.key === \" \" || e.key === \"ArrowUp\") {\n      e.preventDefault();\n      setOpen(true);\n      setFocusIndex(0);\n      requestAnimationFrame(() => emojiRefs.current[0]?.focus());\n    }\n  };\n\n  const onTrayKey = (e: KeyboardEvent<HTMLDivElement>) => {\n    let i = focusIndex;\n    if (e.key === \"ArrowRight\") i = (focusIndex + 1) % options.length;\n    else if (e.key === \"ArrowLeft\") i = (focusIndex - 1 + options.length) % options.length;\n    else if (e.key === \"Home\") i = 0;\n    else if (e.key === \"End\") i = options.length - 1;\n    else if (e.key === \"Escape\") {\n      e.preventDefault();\n      close(true);\n      return;\n    } else return;\n    e.preventDefault();\n    setFocusIndex(i);\n    emojiRefs.current[i]?.focus();\n  };\n\n  const trayLeft = -PAD;\n  const trayTop = -(TRAY_H + GAP);\n  const originX = TRIGGER / 2 - trayLeft;\n\n  return (\n    <div className={cn(\"relative flex flex-wrap items-center gap-1.5\", className)}>\n      <div className=\"relative\" style={{ width: TRIGGER, height: TRIGGER }}>\n        <svg aria-hidden className=\"absolute size-0\">\n          <filter colorInterpolationFilters=\"sRGB\" height=\"140%\" id={filterId} width=\"120%\" x=\"-10%\" y=\"-20%\">\n            <feGaussianBlur in=\"SourceGraphic\" result=\"blur\" stdDeviation=\"5\" />\n            <feColorMatrix in=\"blur\" values=\"1 0 0 0 0  0 1 0 0 0  0 0 1 0 0  0 0 0 24 -11\" />\n          </filter>\n        </svg>\n\n        <div\n          aria-hidden\n          className=\"pointer-events-none absolute transition-[filter] duration-200 ease-out\"\n          style={{\n            left: trayLeft - BLUR_ROOM,\n            top: trayTop - BLUR_ROOM,\n            width: trayW + BLUR_ROOM * 2,\n            height: -trayTop + TRIGGER + BLUR_ROOM * 2,\n            filter: reduce\n              ? undefined\n              : `drop-shadow(0 6px 14px color-mix(in srgb, black ${open ? 12 : 0}%, transparent))`,\n          }}\n        >\n        <div\n          className=\"absolute inset-0\"\n          style={{\n            filter: reduce\n              ? undefined\n              : `url(#${filterId}) drop-shadow(0 0 0.75px color-mix(in srgb, var(--color-foreground) 22%, transparent))`,\n          }}\n        >\n          <div className=\"absolute\" style={{ left: BLUR_ROOM - trayLeft, top: BLUR_ROOM - trayTop }}>\n          <span className=\"absolute top-0 left-0 rounded-full bg-popover\" style={{ width: TRIGGER, height: TRIGGER }} />\n          <AnimatePresence>\n            {open && (\n              <motion.span\n                animate={{ y: 0, scaleX: 1, scaleY: 1, opacity: 1 }}\n                className={cn(\"absolute rounded-full bg-popover\", reduce && \"border shadow-lg/5\")}\n                exit={\n                  reduce\n                    ? { opacity: 0 }\n                    : {\n                        y: TRAY_H * 0.9,\n                        scaleX: 0.14,\n                        scaleY: 0.72,\n                        transition: {\n                          scaleX: { duration: 0.16, ease: [0.4, 0, 0.2, 1] },\n                          scaleY: { duration: 0.16, ease: [0.4, 0, 0.2, 1] },\n                          y: { duration: 0.2, delay: 0.05, ease: [0.4, 0, 0.2, 1] },\n                        },\n                      }\n                }\n                initial={reduce ? { opacity: 0 } : { y: TRAY_H * 0.9, scaleX: 0.14, scaleY: 0.72, opacity: 1 }}\n                style={{ left: trayLeft, top: trayTop, width: trayW, height: TRAY_H, transformOrigin: `${originX}px 100%` }}\n                transition={\n                  reduce\n                    ? { duration: 0.12 }\n                    : {\n                        y: { type: \"spring\", stiffness: 700, damping: 42 },\n                        scaleX: { type: \"spring\", stiffness: 380, damping: 30, delay: 0.04 },\n                        scaleY: { type: \"spring\", stiffness: 420, damping: 30 },\n                      }\n                }\n              />\n            )}\n          </AnimatePresence>\n          </div>\n        </div>\n        </div>\n        {reduce && (\n          <span aria-hidden className=\"pointer-events-none absolute inset-0 rounded-full border bg-popover\" />\n        )}\n\n        <button\n          aria-controls={open ? trayId : undefined}\n          aria-expanded={open}\n          aria-haspopup=\"true\"\n          aria-label=\"Add reaction\"\n          className=\"relative z-10 flex size-8 cursor-pointer touch-none select-none items-center justify-center rounded-full text-muted-foreground outline-none transition-[color,scale] duration-150 ease-out hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-card active:scale-[0.95]\"\n          onBlur={(e) => {\n            const tray = document.getElementById(trayId);\n            if (!tray?.contains(e.relatedTarget as Node)) close();\n          }}\n          onContextMenu={(e) => e.preventDefault()}\n          onKeyDown={onTriggerKey}\n          onPointerCancel={() => clearTimeout(holdTimer.current)}\n          onPointerDown={onPointerDown}\n          onPointerMove={onPointerMove}\n          onPointerUp={onPointerUp}\n          ref={triggerRef}\n          type=\"button\"\n        >\n          <SmilePlusIcon className=\"size-4\" />\n        </button>\n\n        <AnimatePresence>\n          {open && (\n            <motion.div\n              aria-label=\"Pick a reaction\"\n              className=\"absolute z-20 flex items-center\"\n              exit={{ opacity: 0, transition: { duration: 0.08 } }}\n              id={trayId}\n              key=\"tray\"\n              onBlur={(e) => {\n                if (!e.currentTarget.contains(e.relatedTarget as Node) && e.relatedTarget !== triggerRef.current) close();\n              }}\n              onKeyDown={onTrayKey}\n              role=\"toolbar\"\n              style={{ left: trayLeft, top: trayTop, width: trayW, height: TRAY_H, paddingInline: PAD }}\n            >\n              {options.map((o, i) => {\n                const mine = reactions.find((r) => r.emoji === o.emoji)?.mine;\n                const isHot = hovered === i;\n                return (\n                  <motion.button\n                    animate={{ opacity: 1, y: isHot ? -6 : 0, scale: isHot ? 1.32 : 1 }}\n                    aria-label={o.label}\n                    aria-pressed={!!mine}\n                    className=\"group/emoji relative flex shrink-0 cursor-pointer items-center justify-center rounded-full text-[22px] leading-none outline-none focus-visible:ring-2 focus-visible:ring-ring\"\n                    data-emoji={o.emoji}\n                    initial={reduce ? { opacity: 0 } : { opacity: 0, y: 10, scale: 0.6 }}\n                    key={o.emoji}\n                    onClick={() => pick(o.emoji)}\n                    onFocus={() => setFocusIndex(i)}\n                    onPointerEnter={(e) => e.pointerType === \"mouse\" && setHovered(i)}\n                    onPointerLeave={(e) => e.pointerType === \"mouse\" && setHovered((h) => (h === i ? null : h))}\n                    ref={(el) => {\n                      emojiRefs.current[i] = el;\n                    }}\n                    style={{ width: SLOT, height: SLOT }}\n                    tabIndex={i === focusIndex ? 0 : -1}\n                    transition={\n                      reduce\n                        ? { duration: 0.12 }\n                        : {\n                            ...spring,\n                            delay: isHot || hovered !== null ? 0 : 0.05 + i * 0.025,\n                          }\n                    }\n                    type=\"button\"\n                  >\n                    <span aria-hidden>{o.emoji}</span>\n                    {mine && <span aria-hidden className=\"absolute -bottom-0.5 size-1 rounded-full bg-info\" />}\n                    <AnimatePresence>\n                      {isHot && (\n                        <motion.span\n                          animate={{ opacity: 1, y: 0 }}\n                          className=\"pointer-events-none absolute -top-7 whitespace-nowrap rounded-md bg-primary px-1.5 py-0.5 font-medium text-[10px] text-primary-foreground\"\n                          exit={{ opacity: 0, transition: { duration: 0.06 } }}\n                          initial={{ opacity: 0, y: 3 }}\n                          style={{ scale: 1 / 1.32 }}\n                          transition={{ duration: 0.12 }}\n                        >\n                          {o.label}\n                        </motion.span>\n                      )}\n                    </AnimatePresence>\n                  </motion.button>\n                );\n              })}\n            </motion.div>\n          )}\n        </AnimatePresence>\n      </div>\n\n      <AnimatePresence initial={false} mode=\"popLayout\">\n        {reactions.map((r) => {\n          const label = options.find((o) => o.emoji === r.emoji)?.label ?? r.emoji;\n          return (\n            <motion.button\n              animate={{ opacity: 1, scale: 1 }}\n              aria-label={`${label}, ${r.count} ${r.count === 1 ? \"reaction\" : \"reactions\"}${r.mine ? \", including you\" : \"\"}`}\n              aria-pressed={!!r.mine}\n              className={cn(\n                \"flex h-7 cursor-pointer items-center gap-1 rounded-full border px-2 font-medium text-xs outline-none transition-colors duration-150 focus-visible:ring-2 focus-visible:ring-ring active:scale-[0.97]\",\n                r.mine\n                  ? \"border-info/40 bg-info/10 text-info-foreground\"\n                  : \"bg-background text-muted-foreground hover:border-foreground/20 hover:text-foreground dark:bg-input/32\",\n              )}\n              exit={{ opacity: 0, scale: 0.9, transition: { duration: 0.12 } }}\n              initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.85 }}\n              key={r.emoji}\n              layout\n              onClick={() => toggle(r.emoji)}\n              transition={reduce ? { duration: 0 } : { type: \"spring\", stiffness: 600, damping: 36 }}\n              type=\"button\"\n            >\n              <span aria-hidden className=\"text-sm leading-none\">\n                {r.emoji}\n              </span>\n              <Count reduce={reduce} value={r.count} />\n            </motion.button>\n          );\n        })}\n      </AnimatePresence>\n    </div>\n  );\n}\n"
    }
  ]
}