{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"reactions","type":"registry:component","title":"Reactions","description":"Tap a reaction to add yours, or pick one and watch it fly into its pill as the count rolls.","author":"Yash Bavadiya <https://xevrion.dev>","dependencies":["motion"],"registryDependencies":["utils"],"files":[{"path":"components/reactions.tsx","type":"registry:component","content":"\"use client\";\n\nimport { useEffect, useRef, useState } from \"react\";\nimport { AnimatePresence, LayoutGroup, motion } from \"motion/react\";\nimport { useReducedMotion } from \"@/hooks/use-reduced-motion\";\nimport { usePreviewPlay } from \"@/lib/preview-play\";\nimport { cn } from \"@/lib/utils\";\n\nexport type Reaction = { emoji: string; count: number; mine: boolean };\n\nconst EASE_OUT = [0.23, 1, 0.32, 1] as const;\n// The picked emoji's flight into its pill: quick, with a small settle.\nconst FLIGHT = { type: \"spring\", duration: 0.5, bounce: 0.2 } as const;\nconst SHIFT = { type: \"spring\", duration: 0.35, bounce: 0.1 } as const;\n\nconst NAMES: Record<string, string> = {\n  \"👍\": \"thumbs up\",\n  \"❤️\": \"heart\",\n  \"😂\": \"laughing\",\n  \"🎉\": \"party popper\",\n  \"👀\": \"eyes\",\n  \"🙏\": \"thank you\",\n};\nconst CHOICES = Object.keys(NAMES);\n\n// State for a reaction row: toggling your own reaction on a pill, and\n// picking one from the picker, which flies the emoji into its pill.\nexport function useReactions(initial: Reaction[]) {\n  const [reactions, setReactions] = useState(initial);\n  const [open, setOpen] = useState(false);\n  const [landing, setLanding] = useState<string | null>(null);\n  const landTimer = useRef<ReturnType<typeof setTimeout>>(undefined);\n  useEffect(() => () => clearTimeout(landTimer.current), []);\n\n  const toggle = (emoji: string) =>\n    setReactions((rs) => {\n      const found = rs.find((r) => r.emoji === emoji);\n      if (!found) return [...rs, { emoji, count: 1, mine: true }];\n      return rs\n        .map((r) =>\n          r.emoji === emoji\n            ? { ...r, mine: !r.mine, count: r.count + (r.mine ? -1 : 1) }\n            : r,\n        )\n        .filter((r) => r.count > 0);\n    });\n\n  const pick = (emoji: string) => {\n    setLanding(emoji);\n    setOpen(false);\n    // Picking one you've already added takes it back, as tapping its pill\n    // would; picking a new one adds you.\n    toggle(emoji);\n    clearTimeout(landTimer.current);\n    landTimer.current = setTimeout(() => setLanding(null), 700);\n  };\n\n  const reset = () => {\n    clearTimeout(landTimer.current);\n    setReactions(initial);\n    setOpen(false);\n    setLanding(null);\n  };\n\n  return { reactions, open, setOpen, landing, toggle, pick, reset };\n}\n\nexport function Reactions({\n  reactions,\n  open,\n  onOpenChange,\n  landing,\n  onToggle,\n  onPick,\n  className,\n}: {\n  reactions: Reaction[];\n  open: boolean;\n  onOpenChange: (open: boolean) => void;\n  landing: string | null;\n  onToggle: (emoji: string) => void;\n  onPick: (emoji: string) => void;\n  className?: string;\n}) {\n  const reduceMotion = useReducedMotion();\n  const row = useRef<HTMLDivElement>(null);\n  const plus = useRef<HTMLButtonElement>(null);\n  const firstChoice = useRef<HTMLButtonElement>(null);\n  const usingKeys = useRef(false);\n  const [origin, setOrigin] = useState(0);\n\n  const shift = reduceMotion ? { duration: 0 } : SHIFT;\n\n  // Closes on Escape or a press anywhere else.\n  useEffect(() => {\n    if (!open) return;\n    const onDown = (e: PointerEvent) => {\n      if (!row.current?.contains(e.target as Node)) onOpenChange(false);\n    };\n    const onKey = (e: KeyboardEvent) => {\n      if (e.key !== \"Escape\") return;\n      onOpenChange(false);\n      plus.current?.focus();\n    };\n    document.addEventListener(\"pointerdown\", onDown);\n    document.addEventListener(\"keydown\", onKey);\n    return () => {\n      document.removeEventListener(\"pointerdown\", onDown);\n      document.removeEventListener(\"keydown\", onKey);\n    };\n  }, [open, onOpenChange]);\n\n  return (\n    <LayoutGroup>\n      <div\n        ref={row}\n        onPointerDownCapture={() => (usingKeys.current = false)}\n        onKeyDownCapture={() => (usingKeys.current = true)}\n        className={cn(\"relative flex flex-wrap items-center gap-1.5\", className)}\n      >\n        <AnimatePresence initial={false} mode=\"popLayout\">\n          {reactions.map((r) => (\n            <motion.button\n              key={r.emoji}\n              layout\n              type=\"button\"\n              aria-pressed={r.mine}\n              aria-label={`${NAMES[r.emoji] ?? r.emoji}, ${r.count} ${r.count === 1 ? \"person\" : \"people\"}${r.mine ? \", including you\" : \"\"}`}\n              onClick={() => onToggle(r.emoji)}\n              // A new pill opens where it lands; an emptied one folds away.\n              initial={{ opacity: 0, scale: 0.6, filter: \"blur(4px)\" }}\n              animate={{ opacity: 1, scale: 1, filter: \"blur(0px)\" }}\n              exit={{\n                opacity: 0,\n                scale: 0.6,\n                filter: \"blur(4px)\",\n                transition: { duration: 0.15 },\n              }}\n              transition={shift}\n              style={{ borderRadius: 999 }}\n              className={cn(\n                \"flex h-8 items-center gap-1.5 px-2.5 text-[13px] font-medium tabular-nums outline-hidden transition-[background-color,box-shadow,color,scale] duration-150 ease-out focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-solid focus-visible:outline-foreground active:scale-[0.96]\",\n                r.mine\n                  ? \"bg-marker/10 text-foreground shadow-[inset_0_0_0_1px_color-mix(in_oklab,var(--marker)_45%,transparent)]\"\n                  : \"bg-muted text-muted-foreground shadow-[inset_0_0_0_1px_var(--border)] hover:text-foreground\",\n              )}\n            >\n              <motion.span\n                layoutId={landing === r.emoji ? `fly-${r.emoji}` : undefined}\n                layoutCrossfade={false}\n                transition={reduceMotion ? { duration: 0 } : FLIGHT}\n                className=\"text-[15px] leading-none\"\n              >\n                {r.emoji}\n              </motion.span>\n              <Count n={r.count} />\n            </motion.button>\n          ))}\n        </AnimatePresence>\n\n        <motion.button\n          ref={plus}\n          layout\n          transition={shift}\n          type=\"button\"\n          aria-label=\"Add reaction\"\n          aria-expanded={open}\n          onClick={() => {\n            const btn = plus.current;\n            if (btn) setOrigin(btn.offsetLeft + btn.offsetWidth / 2);\n            const next = !open;\n            onOpenChange(next);\n            if (next && usingKeys.current) {\n              requestAnimationFrame(() => firstChoice.current?.focus());\n            }\n          }}\n          style={{ borderRadius: 999 }}\n          className={cn(\n            \"grid h-8 w-9 place-items-center text-muted-foreground outline-hidden transition-[background-color,color,scale] duration-150 ease-out hover:bg-muted hover:text-foreground focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-solid focus-visible:outline-foreground active:scale-[0.96]\",\n            open && \"bg-muted text-foreground\",\n          )}\n        >\n          <svg\n            viewBox=\"0 0 16 16\"\n            className=\"size-4\"\n            fill=\"none\"\n            stroke=\"currentColor\"\n            strokeWidth={1.4}\n            strokeLinecap=\"round\"\n            aria-hidden\n          >\n            <circle cx=\"7.5\" cy=\"8.5\" r=\"5.25\" />\n            <path d=\"M5.6 9.9c.5.6 1.2.95 1.9.95s1.4-.35 1.9-.95M5.9 7h.01M9.1 7h.01\" strokeWidth={1.5} />\n            <path d=\"M13 1.75v3.5M11.25 3.5h3.5\" />\n          </svg>\n        </motion.button>\n\n        {/* Opens just below the row, in the clear rather than over the\n            message, aligned to the row so it never runs off a narrow card,\n            and grows from the button that asked for it. */}\n        <AnimatePresence>\n          {open && (\n            <motion.div\n              role=\"group\"\n              aria-label=\"Pick a reaction\"\n              initial={{ opacity: 0, scale: 0.9, y: -4, filter: \"blur(4px)\" }}\n              animate={{ opacity: 1, scale: 1, y: 0, filter: \"blur(0px)\" }}\n              exit={{\n                opacity: 0,\n                scale: 0.95,\n                y: -2,\n                filter: \"blur(2px)\",\n                transition: { duration: 0.12 },\n              }}\n              transition={\n                reduceMotion ? { duration: 0 } : { duration: 0.2, ease: EASE_OUT }\n              }\n              style={{ transformOrigin: `${origin}px 0%` }}\n              className=\"absolute top-full left-0 z-10 mt-2 flex gap-0.5 rounded-full bg-background p-1 shadow-raised\"\n            >\n              {CHOICES.map((emoji, i) => (\n                <button\n                  key={emoji}\n                  ref={i === 0 ? firstChoice : undefined}\n                  type=\"button\"\n                  aria-label={`React with ${NAMES[emoji]}`}\n                  onClick={() => onPick(emoji)}\n                  className=\"grid size-9 place-items-center rounded-full outline-hidden transition-[background-color,scale] duration-150 ease-out hover:bg-muted focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-solid focus-visible:outline-foreground active:scale-[0.9]\"\n                >\n                  <motion.span\n                    layoutId={`fly-${emoji}`}\n                    className=\"text-[19px] leading-none\"\n                  >\n                    {emoji}\n                  </motion.span>\n                </button>\n              ))}\n            </motion.div>\n          )}\n        </AnimatePresence>\n      </div>\n    </LayoutGroup>\n  );\n}\n\n// The count rolls the way it moved: up when you add yours, down when you\n// take it back.\nfunction Count({ n }: { n: number }) {\n  const [seen, setSeen] = useState({ n, dir: 1 });\n  if (seen.n !== n) setSeen({ n, dir: n > seen.n ? 1 : -1 });\n  const dir = seen.n !== n ? (n > seen.n ? 1 : -1) : seen.dir;\n\n  return (\n    <span className=\"relative inline-flex overflow-hidden\">\n      <AnimatePresence initial={false} mode=\"popLayout\" custom={dir}>\n        <motion.span\n          key={n}\n          custom={dir}\n          variants={{\n            enter: (d: number) => ({ y: d * 10, opacity: 0 }),\n            center: { y: 0, opacity: 1 },\n            exit: (d: number) => ({ y: d * -10, opacity: 0 }),\n          }}\n          initial=\"enter\"\n          animate=\"center\"\n          exit=\"exit\"\n          transition={{ duration: 0.22, ease: EASE_OUT }}\n        >\n          {n}\n        </motion.span>\n      </AnimatePresence>\n    </span>\n  );\n}\n\nconst INITIAL: Reaction[] = [\n  { emoji: \"🎉\", count: 5, mine: true },\n  { emoji: \"👍\", count: 12, mine: false },\n  { emoji: \"👀\", count: 2, mine: false },\n];\n\nexport default function ReactionsDemo() {\n  const play = usePreviewPlay();\n  const r = useReactions(INITIAL);\n  const api = useRef(r);\n  useEffect(() => {\n    api.current = r;\n  });\n\n  // The card's hover show: a thumbs up, a heart from the picker, then both\n  // taken back.\n  useEffect(() => {\n    if (play !== true) return;\n    const timers: ReturnType<typeof setTimeout>[] = [];\n    const at = (ms: number, fn: () => void) => timers.push(setTimeout(fn, ms));\n    const run = () => {\n      at(400, () => api.current.toggle(\"👍\"));\n      at(1300, () => api.current.setOpen(true));\n      at(2100, () => api.current.pick(\"❤️\"));\n      at(3700, () => api.current.toggle(\"❤️\"));\n      at(4400, () => api.current.toggle(\"👍\"));\n      at(5600, run);\n    };\n    run();\n    return () => {\n      timers.forEach(clearTimeout);\n      api.current.reset();\n    };\n  }, [play]);\n\n  return (\n    <div className=\"w-[min(420px,100%)] rounded-2xl bg-background p-5 shadow-raised\">\n      <div className=\"flex items-center gap-2.5\">\n        <span\n          aria-hidden\n          className=\"grid size-8 place-items-center rounded-full bg-muted text-[12px] font-semibold text-foreground shadow-[inset_0_0_0_1px_var(--border)]\"\n        >\n          MR\n        </span>\n        <p className=\"text-[14px] font-medium text-foreground\">Maya Ruiz</p>\n        <p className=\"text-[13px] text-muted-foreground\">2m</p>\n      </div>\n      <p className=\"mt-3 text-[15px] leading-relaxed text-pretty text-foreground/85\">\n        Shipped the new onboarding flow. Thanks everyone for the reviews, it\n        feels so much calmer now.\n      </p>\n      <Reactions\n        className=\"mt-4\"\n        reactions={r.reactions}\n        open={r.open}\n        onOpenChange={r.setOpen}\n        landing={r.landing}\n        onToggle={r.toggle}\n        onPick={r.pick}\n      />\n    </div>\n  );\n}\n"},{"path":"hooks/use-reduced-motion.ts","type":"registry:hook","target":"hooks/use-reduced-motion.ts","content":"\"use client\";\n\nimport { useSyncExternalStore } from \"react\";\n\n// Motion's useReducedMotion reads the media query on the very first client\n// render, but the server can't know it, so every component that renders\n// differently under reduced motion broke hydration for those users. This\n// reports false during hydration, matching the server HTML, then the real\n// preference straight after, and follows it if it changes.\nconst query = \"(prefers-reduced-motion: reduce)\";\n\nfunction subscribe(onChange: () => void) {\n  const media = window.matchMedia(query);\n  media.addEventListener(\"change\", onChange);\n  return () => media.removeEventListener(\"change\", onChange);\n}\n\nexport function useReducedMotion() {\n  return useSyncExternalStore(\n    subscribe,\n    () => window.matchMedia(query).matches,\n    () => false,\n  );\n}\n"},{"path":"lib/preview-play.ts","type":"registry:lib","target":"lib/preview-play.ts","content":"\"use client\";\n\nimport { createContext, useContext } from \"react\";\n\n// Lets a demo know whether it's showing in an index card, and whether that\n// card is hovered or focused. Demos use it to act themselves out on hover:\n// a star button stars itself, a checkbox scribbles, and so on.\n//\n// null: not in a preview (the component's own page, or real use).\n// false: in a preview, at rest.\n// true: in a preview, being hovered or focused: play the show.\nexport const PreviewPlayContext = createContext<boolean | null>(null);\n\nexport function usePreviewPlay() {\n  return useContext(PreviewPlayContext);\n}\n"}],"cssVars":{"light":{"marker":"#d93d31","shadow-raised":"0 0 0 1px oklch(0 0 0 / 0.06), 0 1px 2px oklch(0 0 0 / 0.06), 0 6px 16px -6px oklch(0 0 0 / 0.12)"},"dark":{"marker":"#ff6b5f","shadow-raised":"0 0 0 1px oklch(1 0 0 / 0.08), 0 1px 2px oklch(0 0 0 / 0.4), 0 6px 16px -6px oklch(0 0 0 / 0.6)"},"theme":{"color-marker":"var(--marker)","shadow-raised":"var(--shadow-raised)"}},"categories":["cards"],"docs":"From ui lab: https://lab.xevrion.dev/lab/reactions"}