{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "emoji-reaction",
  "title": "Emoji Reaction",
  "description": "A tapback-style reaction button that opens a bar of Apple emoji and sends copies of your pick floating up out of it.",
  "dependencies": [
    "motion",
    "react-apple-emojis",
    "lucide-react",
    "@radix-ui/react-slot"
  ],
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "components/ui/emoji-reaction.tsx",
      "content": "\"use client\";\n\nimport { memo, useCallback, useEffect, useRef, useState } from \"react\";\nimport type { ComponentProps, KeyboardEvent } from \"react\";\nimport { Slot } from \"@radix-ui/react-slot\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { X } from \"lucide-react\";\nimport { Emoji, EmojiProvider, type EmojiData } from \"react-apple-emojis\";\nimport { cn } from \"@/lib/utils\";\n\n// only the default set, the full emoji map ships 380kb of json\nconst DEFAULT_EMOJI_DATA: EmojiData = {\n  baseUrl: \"https://em-content.zobj.net/source/apple/419/\",\n  emojis: {\n    \"smiling-face-with-hearts\": \"smiling-face-with-hearts_1f970.png\",\n    \"star-struck\": \"star-struck_1f929.png\",\n    \"confused-face\": \"confused-face_1f615.png\",\n    \"pleading-face\": \"pleading-face_1f97a.png\",\n    \"grinning-face-with-smiling-eyes\":\n      \"grinning-face-with-smiling-eyes_1f604.png\",\n  },\n};\n\nconst DEFAULT_EMOJIS = Object.keys(DEFAULT_EMOJI_DATA.emojis);\n\nconst SURFACE = \"bg-[#F4F4F9] dark:bg-[#262626]\";\n\nconst BURST_COUNT = 5;\nconst HOLD_INTERVAL = 550;\nconst MAX_PARTICLES = 60;\nconst RISE = 450;\nconst LAUNCH_SPREAD = 6;\nconst CLIMB_SPREAD = 78;\n// soft ease out, roughly 65% of the distance by the halfway point so it keeps moving\nconst EASE = [0.4, 0.3, 0.5, 1] as const;\nconst SWAY = [0, 0.3, 0.65, 1];\n\nconst GAP = 16;\nconst EDGE = 8;\n\nconst SIZES = {\n  sm: {\n    trigger: \"size-8\",\n    icon: \"size-4\",\n    emoji: 26,\n    pill: \"gap-0.5 p-1\",\n    burst: 26,\n  },\n  md: {\n    trigger: \"size-10\",\n    icon: \"size-5\",\n    emoji: 34,\n    pill: \"gap-1 p-1.5\",\n    burst: 34,\n  },\n  lg: {\n    trigger: \"size-12\",\n    icon: \"size-6\",\n    emoji: 42,\n    pill: \"gap-1.5 p-2\",\n    burst: 42,\n  },\n} as const;\n\ntype Align = \"left\" | \"center\" | \"right\";\n\ntype Placement = { side: \"top\" | \"bottom\"; shift: number; tailX: number };\n\ntype Particle = {\n  id: number;\n  name: string;\n  originX: number;\n  originY: number;\n  x: number;\n  drift: number;\n  tilt: number;\n  travel: number;\n  scale: number;\n  blurRatio: number;\n  fadeAt: number;\n  duration: number;\n  delay: number;\n};\n\nfunction SmileIcon({ className }: { className?: string }) {\n  return (\n    <svg viewBox=\"0 0 24 24\" fill=\"none\" aria-hidden className={className}>\n      <path\n        d=\"M21 12a9 9 0 1 1-9-9\"\n        stroke=\"currentColor\"\n        strokeWidth=\"1.7\"\n        strokeLinecap=\"round\"\n      />\n      <circle cx=\"8.9\" cy=\"10\" r=\"1.35\" fill=\"currentColor\" />\n      <circle cx=\"15.1\" cy=\"10\" r=\"1.35\" fill=\"currentColor\" />\n      <path\n        d=\"M8 13.9a4.7 4.7 0 0 0 8 0\"\n        stroke=\"currentColor\"\n        strokeWidth=\"1.7\"\n        strokeLinecap=\"round\"\n      />\n      <path\n        d=\"M19 2.5v5M21.5 5h-5\"\n        stroke=\"currentColor\"\n        strokeWidth=\"1.7\"\n        strokeLinecap=\"round\"\n      />\n    </svg>\n  );\n}\n\nconst rand = (min: number, max: number) => min + Math.random() * (max - min);\n\nconst label = (name: string) => name.replaceAll(\"-\", \" \");\n\nfunction getPlacement(\n  trigger: DOMRect,\n  width: number,\n  height: number,\n  align: Align,\n): Placement {\n  const anchored =\n    align === \"left\"\n      ? trigger.left\n      : align === \"right\"\n        ? trigger.right - width\n        : trigger.left + trigger.width / 2 - width / 2;\n\n  const overhangLeft = EDGE - anchored;\n  const overhangRight = anchored + width - (window.innerWidth - EDGE);\n  const shift =\n    overhangLeft > 0 ? overhangLeft : overhangRight > 0 ? -overhangRight : 0;\n\n  return {\n    side: trigger.top - height - GAP < EDGE ? \"bottom\" : \"top\",\n    shift,\n    // keeps the tail over the trigger whatever the alignment and shift are\n    tailX: trigger.left + trigger.width / 2 - (anchored + shift),\n  };\n}\n\nfunction makeParticles(\n  name: string,\n  seed: number,\n  from: DOMRect,\n  bar: DOMRect,\n): Particle[] {\n  const originX = from.left + from.width / 2 - bar.left;\n  const originY = from.top + from.height / 2 - bar.top;\n\n  return Array.from({ length: BURST_COUNT }, (_, i) => {\n    const lane = rand(-1, 1);\n    const dir = lane < 0 ? -1 : 1;\n    return {\n      id: seed + i,\n      name,\n      originX,\n      originY,\n      x: lane * LAUNCH_SPREAD,\n      drift: lane * CLIMB_SPREAD,\n      tilt: rand(1, 4) * dir,\n      travel: RISE * rand(0.86, 1),\n      scale: rand(0.78, 1.05),\n      blurRatio: rand(0.18, 0.3),\n      fadeAt: rand(0.55, 0.88),\n      duration: rand(1.4, 1.8),\n      delay: i * 0.25,\n    };\n  });\n}\n\n// memo, a parent render restarts the flight and replays its delay\nconst BurstEmoji = memo(function BurstEmoji({\n  particle,\n  size,\n  onDone,\n}: {\n  particle: Particle;\n  size: number;\n  onDone: (id: number) => void;\n}) {\n  return (\n    <motion.span\n      className=\"pointer-events-none absolute z-0 will-change-transform\"\n      style={{\n        left: particle.originX,\n        top: particle.originY,\n        marginLeft: -size / 2,\n        marginTop: -size / 2,\n      }}\n      initial={{\n        x: particle.x,\n        y: 0,\n        scale: 0.6,\n        opacity: 0,\n        rotate: 0,\n        filter: \"blur(0px)\",\n      }}\n      animate={{\n        // shares the parent ease with y, any override here bends the path sideways\n        x: particle.x + particle.drift,\n        y: -particle.travel,\n        scale: [\n          0.6,\n          particle.scale * 1.15,\n          particle.scale,\n          particle.scale * 0.75,\n        ],\n        rotate: [0, particle.tilt, -particle.tilt * 0.65, particle.tilt * 0.35],\n        opacity: [0, 1, 1, 0],\n        filter: [\n          \"blur(0px)\",\n          \"blur(0px)\",\n          `blur(${particle.blurRatio * size}px)`,\n        ],\n      }}\n      transition={{\n        duration: particle.duration,\n        delay: particle.delay,\n        ease: EASE,\n        // inherit, a per value transition replaces the parent one without it\n        rotate: { inherit: true, times: SWAY, ease: \"easeInOut\" },\n        scale: { inherit: true, times: [0, 0.1, 0.22, 1], ease: \"easeOut\" },\n        opacity: {\n          inherit: true,\n          times: [0, 0.03, particle.fadeAt, 1],\n          ease: \"linear\",\n        },\n        // no ease override, blur has to track the climb curve or it lags the rise\n        filter: { inherit: true, times: [0, 0.12, 1] },\n      }}\n      onAnimationComplete={() => onDone(particle.id)}\n    >\n      <Emoji\n        name={particle.name}\n        width={size}\n        height={size}\n        draggable={false}\n        className=\"max-w-none\"\n      />\n    </motion.span>\n  );\n});\n\nexport type EmojiReactionProps = ComponentProps<\"div\"> & {\n  emojis?: string[];\n  emojiData?: EmojiData;\n  onReact?: (name: string) => void;\n  size?: keyof typeof SIZES;\n  align?: Align;\n  asChild?: boolean;\n};\n\nexport function EmojiReaction({\n  emojis = DEFAULT_EMOJIS,\n  emojiData = DEFAULT_EMOJI_DATA,\n  onReact,\n  size = \"md\",\n  align = \"center\",\n  asChild = false,\n  className,\n  children,\n  ...props\n}: EmojiReactionProps) {\n  const s = SIZES[size];\n  const reduced = useReducedMotion();\n\n  const [open, setOpen] = useState(false);\n  const [last, setLast] = useState<string | null>(null);\n  const [particles, setParticles] = useState<Particle[]>([]);\n  const [placement, setPlacement] = useState<Placement>({\n    side: \"top\",\n    shift: 0,\n    tailX: 0,\n  });\n  const [activeIndex, setActiveIndex] = useState(0);\n\n  const rootRef = useRef<HTMLDivElement>(null);\n  const barRef = useRef<HTMLDivElement | null>(null);\n  const triggerRef = useRef<HTMLElement | null>(null);\n  const itemRefs = useRef<(HTMLButtonElement | null)[]>([]);\n  const seed = useRef(0);\n  const hold = useRef<number | null>(null);\n  const justOpened = useRef(false);\n\n  // stable, an inline callback detaches every commit and is null when placeBar runs\n  const setTriggerRef = useCallback((node: HTMLElement | null) => {\n    triggerRef.current = node;\n  }, []);\n\n  const stopHold = useCallback(() => {\n    if (hold.current === null) return;\n    window.clearInterval(hold.current);\n    hold.current = null;\n  }, []);\n\n  // closing unmounts the copies mid flight, so their completion never fires\n  const close = useCallback(() => {\n    stopHold();\n    setOpen(false);\n    setParticles([]);\n  }, [stopHold]);\n\n  // a ref callback, not an effect, so measuring cannot cascade an extra render pass\n  const placeBar = useCallback(\n    (node: HTMLDivElement | null) => {\n      barRef.current = node;\n      const trigger = triggerRef.current;\n      if (!node || !trigger) return;\n      setPlacement(\n        getPlacement(\n          trigger.getBoundingClientRect(),\n          node.offsetWidth,\n          node.offsetHeight,\n          align,\n        ),\n      );\n    },\n    [align],\n  );\n\n  useEffect(() => {\n    if (!open) return;\n\n    const onPointerDown = (event: globalThis.PointerEvent) => {\n      if (!rootRef.current?.contains(event.target as Node)) close();\n    };\n    const onKeyDown = (event: globalThis.KeyboardEvent) => {\n      if (event.key !== \"Escape\") return;\n      close();\n      triggerRef.current?.focus();\n    };\n\n    document.addEventListener(\"pointerdown\", onPointerDown);\n    document.addEventListener(\"keydown\", onKeyDown);\n    return () => {\n      document.removeEventListener(\"pointerdown\", onPointerDown);\n      document.removeEventListener(\"keydown\", onKeyDown);\n    };\n  }, [open, close]);\n\n  useEffect(() => {\n    if (open) itemRefs.current[0]?.focus();\n  }, [open]);\n\n  const react = useCallback(\n    (name: string, from: DOMRect) => {\n      setLast(name);\n      onReact?.(name);\n\n      const bar = barRef.current?.getBoundingClientRect();\n      if (reduced || !bar) return;\n      seed.current += BURST_COUNT;\n      setParticles((prev) =>\n        [...prev, ...makeParticles(name, seed.current, from, bar)].slice(\n          -MAX_PARTICLES,\n        ),\n      );\n    },\n    [onReact, reduced],\n  );\n\n  const startHold = useCallback(\n    (name: string, from: DOMRect) => {\n      react(name, from);\n      stopHold();\n      hold.current = window.setInterval(() => react(name, from), HOLD_INTERVAL);\n    },\n    [react, stopHold],\n  );\n\n  useEffect(() => stopHold, [stopHold]);\n\n  const settle = useCallback((id: number) => {\n    setParticles((prev) => prev.filter((particle) => particle.id !== id));\n  }, []);\n\n  // press the trigger and drag along the bar, releasing over an emoji picks it\n  const onTriggerPointerDown = useCallback(() => {\n    if (open) return;\n    setOpen(true);\n    justOpened.current = true;\n\n    const up = (event: globalThis.PointerEvent) => {\n      document.removeEventListener(\"pointerup\", up);\n\n      const target = document.elementFromPoint(\n        event.clientX,\n        event.clientY,\n      ) as HTMLElement | null;\n\n      const picked = target?.closest<HTMLElement>(\"[data-emoji]\");\n      if (picked?.dataset.emoji) {\n        react(picked.dataset.emoji, picked.getBoundingClientRect());\n      }\n\n      // releasing off the trigger fires no click, so nothing else would clear the guard\n      if (!triggerRef.current?.contains(target)) justOpened.current = false;\n    };\n\n    document.addEventListener(\"pointerup\", up);\n  }, [open, react]);\n\n  const onMenuKeyDown = useCallback(\n    (event: KeyboardEvent<HTMLDivElement>) => {\n      const count = emojis.length;\n      let next = activeIndex;\n\n      if (event.key === \"ArrowRight\") next = (activeIndex + 1) % count;\n      else if (event.key === \"ArrowLeft\")\n        next = (activeIndex - 1 + count) % count;\n      else if (event.key === \"Home\") next = 0;\n      else if (event.key === \"End\") next = count - 1;\n      else return;\n\n      event.preventDefault();\n      setActiveIndex(next);\n      itemRefs.current[next]?.focus();\n    },\n    [activeIndex, emojis.length],\n  );\n\n  const burst = particles.map((particle) => (\n    <BurstEmoji\n      key={particle.id}\n      particle={particle}\n      size={s.burst}\n      onDone={settle}\n    />\n  ));\n\n  const Trigger = asChild ? Slot : \"button\";\n\n  const top = placement.side === \"top\";\n  const anchor =\n    align === \"left\" ? \"left-0\" : align === \"right\" ? \"right-0\" : \"left-1/2\";\n  const centering = align === \"center\" ? \"-50%\" : 0;\n  // right anchored bars pin their right edge, so a left margin cannot move them\n  const nudge =\n    align === \"right\"\n      ? { marginRight: -placement.shift }\n      : { marginLeft: placement.shift };\n\n  return (\n    <EmojiProvider data={emojiData}>\n      <div\n        ref={rootRef}\n        data-slot=\"emoji-reaction\"\n        className={cn(\"relative flex w-fit items-center\", className)}\n        {...props}\n      >\n        <AnimatePresence>\n          {open && (\n            <motion.div\n              className={cn(\n                \"absolute z-30\",\n                anchor,\n                top ? \"bottom-full mb-4\" : \"top-full mt-4\",\n              )}\n              initial={{\n                opacity: 0,\n                y: top ? 10 : -10,\n                scale: 0.85,\n                x: centering,\n              }}\n              animate={{ opacity: 1, y: 0, scale: 1, x: centering }}\n              exit={{ opacity: 0, y: top ? 6 : -6, scale: 0.9, x: centering }}\n              transition={\n                reduced\n                  ? { duration: 0.15 }\n                  : { type: \"spring\", stiffness: 520, damping: 30 }\n              }\n              style={{ originY: top ? 1 : 0, ...nudge }}\n            >\n              <div\n                ref={placeBar}\n                role=\"menu\"\n                aria-label=\"Pick a reaction\"\n                aria-orientation=\"horizontal\"\n                onKeyDown={onMenuKeyDown}\n                className={cn(\n                  \"relative flex items-center rounded-full\",\n                  SURFACE,\n                  s.pill,\n                )}\n              >\n                {burst}\n\n                {emojis.map((name, i) => (\n                  <motion.button\n                    key={`${name}-${i}`}\n                    ref={(node) => {\n                      itemRefs.current[i] = node;\n                    }}\n                    type=\"button\"\n                    role=\"menuitem\"\n                    tabIndex={i === activeIndex ? 0 : -1}\n                    data-emoji={name}\n                    aria-label={label(name)}\n                    onFocus={() => setActiveIndex(i)}\n                    onPointerDown={(event) =>\n                      startHold(\n                        name,\n                        event.currentTarget.getBoundingClientRect(),\n                      )\n                    }\n                    onPointerUp={stopHold}\n                    onPointerLeave={stopHold}\n                    onPointerCancel={stopHold}\n                    // detail is 0 only for keyboard, pointer already fired above\n                    onClick={(event) =>\n                      event.detail === 0 &&\n                      react(name, event.currentTarget.getBoundingClientRect())\n                    }\n                    className=\"relative z-10 rounded-full p-1 outline-none focus-visible:ring-2 focus-visible:ring-ring\"\n                    initial={reduced ? false : { scale: 0.4, opacity: 0 }}\n                    animate={{ scale: 1, opacity: 1 }}\n                    transition={{\n                      type: \"spring\",\n                      stiffness: 800,\n                      damping: 25,\n                      delay: reduced ? 0 : 0.04 + i * 0.035,\n                    }}\n                    whileHover={reduced ? undefined : { scale: 1.28, y: -4 }}\n                    whileTap={{ scale: 0.92 }}\n                  >\n                    <Emoji\n                      name={name}\n                      width={s.emoji}\n                      height={s.emoji}\n                      draggable={false}\n                      className=\"max-w-none\"\n                    />\n                  </motion.button>\n                ))}\n              </div>\n\n              <span\n                className={cn(\n                  \"absolute size-3 -translate-x-1/2 rounded-full\",\n                  SURFACE,\n                  top ? \"-bottom-1\" : \"-top-1\",\n                )}\n                style={{ left: placement.tailX }}\n              />\n              <span\n                className={cn(\n                  \"absolute size-1.5 -translate-x-1/2 rounded-full\",\n                  SURFACE,\n                  top ? \"-bottom-4\" : \"-top-4\",\n                )}\n                style={{ left: placement.tailX + 6 }}\n              />\n            </motion.div>\n          )}\n        </AnimatePresence>\n\n        <Trigger\n          ref={setTriggerRef}\n          type={asChild ? undefined : \"button\"}\n          aria-haspopup=\"true\"\n          aria-expanded={open}\n          aria-label={\n            open\n              ? \"Close reactions\"\n              : last\n                ? `Reacted ${label(last)}`\n                : \"Add a reaction\"\n          }\n          onPointerDown={onTriggerPointerDown}\n          onClick={() => {\n            if (justOpened.current) {\n              justOpened.current = false;\n              return;\n            }\n            if (open) close();\n            else setOpen(true);\n          }}\n          className={\n            asChild\n              ? undefined\n              : cn(\n                  \"relative z-10 grid place-items-center rounded-full text-foreground/60 transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring\",\n                  SURFACE,\n                  s.trigger,\n                )\n          }\n        >\n          {asChild ? (\n            children\n          ) : open ? (\n            <X className={s.icon} strokeWidth={2} />\n          ) : last ? (\n            <Emoji\n              name={last}\n              width={s.emoji * 0.72}\n              height={s.emoji * 0.72}\n              draggable={false}\n              className=\"max-w-none\"\n            />\n          ) : (\n            <SmileIcon className={s.icon} />\n          )}\n        </Trigger>\n      </div>\n    </EmojiProvider>\n  );\n}\n\nexport default EmojiReaction;\n",
      "type": "registry:ui"
    }
  ],
  "type": "registry:ui"
}