{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "reaction-button",
  "type": "registry:ui",
  "title": "Reaction Button",
  "description": "A like toggle that pops with a spring, fires a ring and particle burst, and rolls its count digit by digit.",
  "author": "WANSTUDIO <https://wanstudio.my>",
  "dependencies": [
    "motion"
  ],
  "files": [
    {
      "path": "registry/wancraft/reaction-button.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { AnimatePresence, animate, motion, useMotionValue } from \"motion/react\";\n\nexport type ReactionGlyph = \"heart\" | \"star\" | \"thumb\" | \"bookmark\";\n\nexport interface ReactionButtonProps extends Omit<React.ComponentPropsWithoutRef<\"button\">, \"children\" | \"onChange\"> {\n  /** Controlled pressed state. When set, `count` is shown exactly as passed. */\n  pressed?: boolean;\n  /** Initial pressed state when uncontrolled. */\n  defaultPressed?: boolean;\n  /** Called on toggle with the next state and the count the button suggests (current ± 1). */\n  onPressedChange?: (pressed: boolean, count: number) => void;\n  /**\n   * Total number of reactions, including the viewer's own when pressed. Uncontrolled buttons\n   * add or remove the viewer's reaction on top of it.\n   */\n  count?: number;\n  /** Accessible name; the formatted count is appended, e.g. \"Like, 128\". */\n  label?: string;\n  /** Built-in glyph, or a render function receiving the pressed state. */\n  icon?: ReactionGlyph | ((pressed: boolean) => React.ReactNode);\n  /** Colour of the active icon, ring and particles. */\n  accent?: string;\n  size?: \"sm\" | \"md\" | \"lg\";\n  /** Bordered capsule or a bare icon + count. */\n  variant?: \"pill\" | \"ghost\";\n  showCount?: boolean;\n  /** Ring shockwave and particle burst when the reaction is added. */\n  burst?: boolean;\n  /** Number of burst particles. */\n  particles?: number;\n  /** Formats the count. Digits roll individually; other characters stay put. */\n  formatCount?: (count: number) => string;\n}\n\nconst GLYPHS: Record<ReactionGlyph, string> = {\n  heart:\n    \"M12 20.4s-7.9-4.6-9.2-10.1C1.9 6.6 4.4 3.8 7.4 3.8c1.9 0 3.6 1 4.6 2.6 1-1.6 2.7-2.6 4.6-2.6 3 0 5.5 2.8 4.6 6.5-1.3 5.5-9.2 10.1-9.2 10.1Z\",\n  star: \"M12 3.4l2.6 5.5 6 .8-4.4 4.2 1.1 6-5.3-2.9-5.3 2.9 1.1-6-4.4-4.2 6-.8Z\",\n  thumb: \"M8 10.6 11.3 4c1.2-.3 2.6.5 2.4 2.1l-.5 3.3h4.9a2 2 0 0 1 2 2.4l-1.2 6.2a2 2 0 0 1-2 1.6H8ZM3.6 10.6H6v9H3.6Z\",\n  bookmark: \"M6.6 3.6h10.8c.6 0 1 .4 1 1v15.8L12 16.4l-6.4 4V4.6c0-.6.4-1 1-1Z\",\n};\n\nconst SIZES = {\n  sm: { button: \"h-8 gap-1.5 px-2.5 text-xs\", icon: 16 },\n  md: { button: \"h-10 gap-2 px-3.5 text-sm\", icon: 18 },\n  lg: { button: \"h-12 gap-2.5 px-4 text-base\", icon: 22 },\n} as const;\n\nconst defaultFormat = (n: number) =>\n  new Intl.NumberFormat(\"en-US\", { notation: Math.abs(n) >= 10000 ? \"compact\" : \"standard\", maximumFractionDigits: 1 }).format(n);\n\nfunction subscribeReducedMotion(cb: () => void) {\n  const mq = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n  mq.addEventListener(\"change\", cb);\n  return () => mq.removeEventListener(\"change\", cb);\n}\n\nfunction usePrefersReducedMotion() {\n  return React.useSyncExternalStore(\n    subscribeReducedMotion,\n    () => window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches,\n    () => false,\n  );\n}\n\nconst digitVariants = {\n  enter: (dir: number) => ({ y: dir > 0 ? \"100%\" : \"-100%\", opacity: 0 }),\n  center: { y: \"0%\", opacity: 1 },\n  exit: (dir: number) => ({ y: dir > 0 ? \"-100%\" : \"100%\", opacity: 0 }),\n};\n\n/** One digit position. Only positions whose digit changed animate, in the direction of change. */\nfunction DigitSlot({ digit, dir }: { digit: string; dir: number }) {\n  return (\n    <span className=\"relative inline-flex h-[1.2em] overflow-hidden leading-[1.2em]\">\n      <AnimatePresence initial={false} custom={dir} mode=\"popLayout\">\n        <motion.span\n          key={digit}\n          custom={dir}\n          variants={digitVariants}\n          initial=\"enter\"\n          animate=\"center\"\n          exit=\"exit\"\n          transition={{ type: \"spring\", stiffness: 420, damping: 34 }}\n          className=\"inline-block\"\n        >\n          {digit}\n        </motion.span>\n      </AnimatePresence>\n    </span>\n  );\n}\n\n/** Ring shockwave + particles. Mounted fresh (by key) for every activation. */\nfunction Burst({ count, radius }: { count: number; radius: number }) {\n  const n = Math.max(0, Math.min(24, Math.round(count)));\n  return (\n    <span aria-hidden=\"true\" className=\"pointer-events-none absolute left-1/2 top-1/2 size-0\">\n      <motion.span\n        className=\"absolute rounded-full border-[var(--rb-accent)]\"\n        style={{ width: radius * 2, height: radius * 2, left: -radius, top: -radius }}\n        initial={{ scale: 0.35, opacity: 0.9, borderWidth: radius * 0.45 }}\n        animate={{ scale: 1.7, opacity: 0, borderWidth: 0.5 }}\n        transition={{ duration: 0.55, ease: [0.22, 1, 0.36, 1] }}\n      />\n      {Array.from({ length: n }, (_, i) => {\n        // Deterministic spread: even angles with a small alternating offset in angle and distance.\n        const angle = ((i / n) * 360 + (i % 2 ? 12 : -4)) * (Math.PI / 180);\n        const distance = radius * (i % 2 ? 1.25 : 1.6);\n        const dot = i % 3 === 0 ? 4 : 3;\n        return (\n          <motion.span\n            key={i}\n            className={`absolute rounded-full ${i % 2 ? \"bg-[color-mix(in_oklab,var(--rb-accent)_55%,white)]\" : \"bg-[var(--rb-accent)]\"}`}\n            style={{ width: dot, height: dot, left: -dot / 2, top: -dot / 2 }}\n            initial={{ x: 0, y: 0, scale: 1, opacity: 1 }}\n            animate={{ x: Math.cos(angle) * distance, y: Math.sin(angle) * distance, scale: 0.2, opacity: 0 }}\n            transition={{ duration: 0.6, delay: 0.04, ease: [0.16, 1, 0.3, 1] }}\n          />\n        );\n      })}\n    </span>\n  );\n}\n\n/**\n * A like / reaction toggle. Adding a reaction pops the icon with a spring and fires a ring\n * shockwave and particle burst; the count rolls digit by digit up or down from its previous value.\n */\nexport const ReactionButton = React.forwardRef<HTMLButtonElement, ReactionButtonProps>(function ReactionButton(\n  {\n    pressed: pressedProp,\n    defaultPressed = false,\n    onPressedChange,\n    count = 0,\n    label = \"Like\",\n    icon = \"heart\",\n    accent = \"#FF7448\",\n    size = \"md\",\n    variant = \"pill\",\n    showCount = true,\n    burst = true,\n    particles = 8,\n    formatCount = defaultFormat,\n    className = \"\",\n    style,\n    onClick,\n    ...props\n  },\n  ref,\n) {\n  const reduceMotion = usePrefersReducedMotion();\n  const controlled = pressedProp !== undefined;\n  const [innerPressed, setInnerPressed] = React.useState(defaultPressed);\n  const pressed = controlled ? pressedProp : innerPressed;\n  // Uncontrolled: `count` is the total for `defaultPressed`; toggling adds or removes the viewer.\n  const total = controlled ? count : count + (pressed === defaultPressed ? 0 : pressed ? 1 : -1);\n  const formatted = formatCount(total);\n\n  // Remember the previous total during render so each change rolls in the right direction.\n  const [roll, setRoll] = React.useState({ value: total, dir: 1 });\n  if (roll.value !== total) setRoll({ value: total, dir: total > roll.value ? 1 : -1 });\n\n  const [burstId, setBurstId] = React.useState(0);\n  const iconScale = useMotionValue(1);\n  const sizes = SIZES[size] ?? SIZES.md;\n\n  const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {\n    onClick?.(event);\n    if (event.defaultPrevented) return;\n    const next = !pressed;\n    if (!controlled) setInnerPressed(next);\n    onPressedChange?.(next, total + (next ? 1 : -1));\n    if (reduceMotion) return;\n    if (next) {\n      // Squash, then release with velocity so the spring overshoots into a pop.\n      iconScale.set(0.55);\n      animate(iconScale, 1, { type: \"spring\", stiffness: 520, damping: 11, velocity: 10 });\n      if (burst) setBurstId((id) => id + 1);\n    } else {\n      iconScale.set(0.82);\n      animate(iconScale, 1, { type: \"spring\", stiffness: 600, damping: 22 });\n    }\n  };\n\n  const glyph =\n    typeof icon === \"function\" ? (\n      icon(pressed)\n    ) : (\n      <svg viewBox=\"0 0 24 24\" width={sizes.icon} height={sizes.icon} aria-hidden=\"true\" className=\"overflow-visible\">\n        <path\n          d={GLYPHS[icon] ?? GLYPHS.heart}\n          fill={pressed ? \"currentColor\" : \"none\"}\n          stroke=\"currentColor\"\n          strokeWidth={1.6}\n          strokeLinejoin=\"round\"\n          className=\"transition-[fill] duration-150\"\n        />\n      </svg>\n    );\n\n  const surface =\n    variant === \"ghost\"\n      ? \"border-transparent bg-transparent px-1.5 hover:bg-zinc-900/5 dark:hover:bg-white/5\"\n      : pressed\n        ? \"border-[color-mix(in_oklab,var(--rb-accent)_45%,transparent)] bg-[color-mix(in_oklab,var(--rb-accent)_10%,transparent)]\"\n        : \"border-zinc-900/12 bg-white/60 hover:border-zinc-900/25 dark:border-white/12 dark:bg-white/[0.03] dark:hover:border-white/25\";\n\n  const chars = formatted.split(\"\");\n\n  return (\n    <button\n      ref={ref}\n      type=\"button\"\n      aria-pressed={pressed}\n      aria-label={showCount ? `${label}, ${formatted}` : label}\n      data-state={pressed ? \"on\" : \"off\"}\n      onClick={handleClick}\n      className={`group relative inline-flex shrink-0 select-none items-center rounded-full border font-medium tabular-nums tracking-tight text-zinc-700 transition-[background-color,border-color,color] duration-200 [-webkit-tap-highlight-color:transparent] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--rb-accent)] disabled:cursor-not-allowed disabled:opacity-50 dark:text-zinc-300 ${sizes.button} ${surface} ${className}`}\n      style={{ [\"--rb-accent\" as string]: accent, ...style }}\n      {...props}\n    >\n      <span\n        aria-hidden=\"true\"\n        className={`relative grid place-items-center transition-colors duration-150 ${pressed ? \"text-[var(--rb-accent)]\" : \"group-hover:text-zinc-950 dark:group-hover:text-white\"}`}\n        style={{ width: sizes.icon, height: sizes.icon }}\n      >\n        {burstId > 0 && !reduceMotion && <Burst key={burstId} count={particles} radius={sizes.icon} />}\n        <motion.span className=\"grid place-items-center\" style={{ scale: iconScale }}>\n          {glyph}\n        </motion.span>\n      </span>\n      {showCount && (\n        <span aria-hidden=\"true\" className={`inline-flex h-[1.2em] items-center leading-[1.2em] ${pressed ? \"text-zinc-950 dark:text-white\" : \"\"}`}>\n          {reduceMotion\n            ? formatted\n            : // Keys count from the right so units, tens… keep their slot when the length changes.\n              chars.map((ch, i) => {\n                const key = chars.length - i;\n                return /\\d/.test(ch) ? <DigitSlot key={key} digit={ch} dir={roll.dir} /> : <span key={key}>{ch}</span>;\n              })}\n        </span>\n      )}\n    </button>\n  );\n});\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "buttons"
  ],
  "docs": "Documentation: https://wancraft.my/components/reaction-button"
}
