{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "like-button",
  "title": "Like Button",
  "description": "A heart like button micro-interaction with a spring pop, ring pulse, radial particle burst, and a rolling odometer count",
  "dependencies": [
    "framer-motion"
  ],
  "files": [
    {
      "path": "app/registry/like-button/like-button.tsx",
      "content": "/**\n * Spectrum UI — LikeButton\n *\n * A heart like-button micro-interaction. Liking pops the heart with a spring\n * scale, fires a ring pulse plus a radial particle burst, and rolls the count\n * up like an odometer; unliking rolls it back down. Works controlled or\n * uncontrolled, honors prefers-reduced-motion, and exposes its state to\n * screen readers via aria-pressed.\n *\n * Dependencies: framer-motion, @/lib/utils\n *\n * @example\n * <LikeButton count={128} onLikedChange={(liked) => save(liked)} />\n */\n\n\"use client\"\n\nimport React, { useCallback, useRef, useState } from \"react\"\nimport {\n  AnimatePresence,\n  motion,\n  useReducedMotion,\n  type Transition,\n} from \"framer-motion\"\nimport { cn } from \"@/lib/utils\"\n\n// ─── Types ───────────────────────────────────────────────────────────────────\n\nexport interface LikeButtonProps {\n  /** Controlled liked state. Leave undefined for uncontrolled usage */\n  liked?: boolean\n  /** Initial liked state when uncontrolled. Default false */\n  defaultLiked?: boolean\n  /** Fires with the next liked state on every toggle */\n  onLikedChange?: (liked: boolean) => void\n  /** Like total excluding the current user; +1 is shown while liked */\n  count?: number\n  /** Hide the rolling counter even when count is provided. Default true */\n  showCount?: boolean\n  /** Visual size of the button. Default \"md\" */\n  size?: \"sm\" | \"md\" | \"lg\"\n  /** Colors cycled across the burst particles */\n  particleColors?: string[]\n  /** Disables pointer and keyboard interaction */\n  disabled?: boolean\n  /** Accessible name of the action. Default \"Like\" */\n  label?: string\n  className?: string\n}\n\n// ─── Constants ───────────────────────────────────────────────────────────────\n\nconst PARTICLE_COUNT = 8\nconst BURST_DURATION = 0.55\n\n/** Snappy micro spring for the heart fill scaling in */\nconst FILL_SPRING: Transition = { type: \"spring\", stiffness: 500, damping: 30 }\n/** Tight tween for the un-fill so the heart never overshoots past zero scale */\nconst UNFILL_TWEEN: Transition = { duration: 0.15, ease: \"easeOut\" }\n/** Spring for the rolling odometer digits */\nconst COUNT_SPRING: Transition = { type: \"spring\", stiffness: 400, damping: 30 }\n/** Celebratory squash-and-stretch pop when liking */\nconst POP_KEYFRAMES = [1, 0.6, 1.3, 1]\nconst POP_TRANSITION: Transition = {\n  duration: 0.45,\n  times: [0, 0.25, 0.6, 1],\n  ease: \"easeOut\",\n}\n/** The heart's visual mass sits below center, so pops anchor slightly low */\nconst HEART_ORIGIN = \"50% 60%\"\n\nconst DEFAULT_PARTICLE_COLORS = [\n  \"#f43f5e\",\n  \"#fb923c\",\n  \"#facc15\",\n  \"#4ade80\",\n  \"#38bdf8\",\n  \"#a78bfa\",\n]\n\nconst SIZES = {\n  sm: { button: \"h-8 gap-1.5 px-3 text-xs\", icon: 14 },\n  md: { button: \"h-10 gap-2 px-4 text-sm\", icon: 17 },\n  lg: { button: \"h-12 gap-2.5 px-5 text-base\", icon: 20 },\n} as const\n\nconst HEART_PATH =\n  \"M19 14c1.49-1.46 3-3.21 3-5.5A5.5 5.5 0 0 0 16.5 3c-1.76 0-3 .5-4.5 2-1.5-1.5-2.74-2-4.5-2A5.5 5.5 0 0 0 2 8.5c0 2.3 1.5 4.05 3 5.5l7 7Z\"\n\nconst countVariants = {\n  enter: (direction: number) => ({ y: direction * 12, opacity: 0 }),\n  center: { y: 0, opacity: 1 },\n  exit: (direction: number) => ({ y: direction * -12, opacity: 0 }),\n}\n\nfunction formatCount(value: number) {\n  return value >= 1000\n    ? new Intl.NumberFormat(\"en\", {\n        notation: \"compact\",\n        maximumFractionDigits: 1,\n      }).format(value)\n    : String(value)\n}\n\n// ─── Component ───────────────────────────────────────────────────────────────\n\nexport function LikeButton({\n  liked: likedProp,\n  defaultLiked = false,\n  onLikedChange,\n  count,\n  showCount = true,\n  size = \"md\",\n  particleColors = DEFAULT_PARTICLE_COLORS,\n  disabled = false,\n  label = \"Like\",\n  className,\n}: LikeButtonProps) {\n  const shouldReduceMotion = useReducedMotion()\n  const [internalLiked, setInternalLiked] = useState(defaultLiked)\n  // Monotonic id per burst; null while idle so unmount/cleanup can't race a re-click\n  const [burst, setBurst] = useState<number | null>(null)\n  const burstIdRef = useRef(0)\n\n  const liked = likedProp ?? internalLiked\n  const { button: sizeClasses, icon } = SIZES[size]\n  const displayCount = count !== undefined ? count + (liked ? 1 : 0) : undefined\n  // While liked the count just went up, so digits roll upward (and vice versa)\n  const direction = liked ? 1 : -1\n  // Reserve the wider of the liked/unliked renderings so toggling never\n  // shifts the pill width (tabular-nums keeps digit widths uniform)\n  const countWidthCh =\n    count !== undefined\n      ? Math.max(formatCount(count).length, formatCount(count + 1).length)\n      : 0\n\n  const handleClick = useCallback(() => {\n    const next = !liked\n    if (likedProp === undefined) setInternalLiked(next)\n    onLikedChange?.(next)\n    if (next && !shouldReduceMotion) {\n      burstIdRef.current += 1\n      setBurst(burstIdRef.current)\n    }\n  }, [liked, likedProp, onLikedChange, shouldReduceMotion])\n\n  return (\n    <motion.button\n      type=\"button\"\n      onClick={handleClick}\n      disabled={disabled}\n      aria-pressed={liked}\n      aria-label={\n        displayCount !== undefined && showCount\n          ? `${label} (${displayCount})`\n          : label\n      }\n      whileTap={shouldReduceMotion ? undefined : { scale: 0.94 }}\n      className={cn(\n        \"group relative inline-flex touch-manipulation select-none items-center justify-center rounded-full border font-medium transition-colors\",\n        \"shadow-[0px_1px_2px_0px_rgba(0,0,0,0.04),0px_2px_4px_0px_rgba(0,0,0,0.04)] dark:shadow-none\",\n        \"focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-neutral-950 dark:focus-visible:ring-neutral-300\",\n        \"disabled:pointer-events-none disabled:opacity-50\",\n        liked\n          ? \"border-rose-200 bg-rose-50 text-rose-600 hover:border-rose-300 hover:bg-rose-100/70 active:bg-rose-100 dark:border-rose-500/30 dark:bg-rose-500/10 dark:text-rose-400 dark:hover:border-rose-500/50 dark:hover:bg-rose-500/15 dark:active:bg-rose-500/20\"\n          : \"border-neutral-200 bg-white text-neutral-600 hover:border-rose-200 hover:bg-rose-50/60 hover:text-rose-500 active:bg-rose-50 dark:border-neutral-800 dark:bg-neutral-900 dark:text-neutral-400 dark:hover:border-rose-500/40 dark:hover:bg-rose-500/6 dark:hover:text-rose-400 dark:active:bg-rose-500/10\",\n        sizeClasses,\n        className,\n      )}\n    >\n      <motion.span\n        className=\"relative flex items-center justify-center\"\n        style={{ transformOrigin: HEART_ORIGIN }}\n        initial={false}\n        animate={\n          liked && !shouldReduceMotion\n            ? { scale: POP_KEYFRAMES }\n            : { scale: 1 }\n        }\n        transition={POP_TRANSITION}\n      >\n        <span\n          className=\"relative inline-flex\"\n          style={{ width: icon, height: icon }}\n        >\n          <svg\n            viewBox=\"0 0 24 24\"\n            width={icon}\n            height={icon}\n            fill=\"none\"\n            stroke=\"currentColor\"\n            strokeWidth=\"2\"\n            strokeLinecap=\"round\"\n            strokeLinejoin=\"round\"\n            aria-hidden=\"true\"\n          >\n            <path d={HEART_PATH} />\n          </svg>\n          <motion.svg\n            viewBox=\"0 0 24 24\"\n            width={icon}\n            height={icon}\n            className=\"absolute inset-0 text-rose-500 dark:text-rose-400\"\n            style={{ transformOrigin: HEART_ORIGIN }}\n            fill=\"currentColor\"\n            stroke=\"currentColor\"\n            strokeWidth=\"2\"\n            strokeLinecap=\"round\"\n            strokeLinejoin=\"round\"\n            aria-hidden=\"true\"\n            initial={false}\n            animate={{ scale: liked ? 1 : 0, opacity: liked ? 1 : 0 }}\n            transition={\n              shouldReduceMotion\n                ? { duration: 0 }\n                : liked\n                  ? FILL_SPRING\n                  : UNFILL_TWEEN\n            }\n          >\n            <path d={HEART_PATH} />\n          </motion.svg>\n        </span>\n\n        {burst !== null && !shouldReduceMotion && (\n          <span\n            aria-hidden=\"true\"\n            className=\"pointer-events-none absolute inset-0 flex items-center justify-center\"\n          >\n            <motion.span\n              key={`ring-${burst}`}\n              className=\"absolute rounded-full border-2 border-rose-400\"\n              style={{ width: icon * 1.6, height: icon * 1.6 }}\n              initial={{ scale: 0.3, opacity: 0.9 }}\n              animate={{ scale: 2, opacity: 0 }}\n              transition={{ duration: BURST_DURATION, ease: \"easeOut\" }}\n              // Only clear if a newer burst hasn't replaced this one meanwhile\n              onAnimationComplete={() =>\n                setBurst((current) => (current === burst ? null : current))\n              }\n            />\n            {Array.from({ length: PARTICLE_COUNT }).map((_, index) => {\n              const angle = (index / PARTICLE_COUNT) * Math.PI * 2 - Math.PI / 2\n              const distance = icon * (index % 2 === 0 ? 1.9 : 1.5)\n              const dotSize = Math.max(3, Math.round(icon * 0.26))\n              return (\n                <motion.span\n                  key={`particle-${burst}-${index}`}\n                  className=\"absolute rounded-full\"\n                  style={{\n                    width: dotSize,\n                    height: dotSize,\n                    backgroundColor:\n                      particleColors[index % particleColors.length],\n                  }}\n                  initial={{ x: 0, y: 0, scale: 0, opacity: 1 }}\n                  animate={{\n                    x: Math.cos(angle) * distance,\n                    y: Math.sin(angle) * distance,\n                    scale: [0, 1, 0.4],\n                    opacity: [1, 1, 0],\n                  }}\n                  transition={{ duration: BURST_DURATION, ease: \"easeOut\" }}\n                />\n              )\n            })}\n          </span>\n        )}\n      </motion.span>\n\n      {displayCount !== undefined && showCount && (\n        <span\n          className=\"relative inline-flex justify-center overflow-hidden tabular-nums\"\n          style={{ minWidth: `${countWidthCh}ch` }}\n          aria-hidden=\"true\"\n        >\n          <AnimatePresence mode=\"popLayout\" initial={false} custom={direction}>\n            <motion.span\n              key={displayCount}\n              className=\"inline-block\"\n              custom={direction}\n              variants={countVariants}\n              initial=\"enter\"\n              animate=\"center\"\n              exit=\"exit\"\n              transition={shouldReduceMotion ? { duration: 0 } : COUNT_SPRING}\n            >\n              {formatCount(displayCount)}\n            </motion.span>\n          </AnimatePresence>\n        </span>\n      )}\n    </motion.button>\n  )\n}\n",
      "type": "registry:component",
      "target": "components/spectrumui/like-button.tsx"
    }
  ],
  "type": "registry:component"
}
