{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "like-button",
  "title": "Like Button",
  "description": "Animated like button for React and shadcn: the heart pops with a burst of sparks, and the optimistic count reverts if saving fails.",
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "https://eased.dev/r/eased-tokens.json",
    "https://eased.dev/r/eased-motion.json",
    "https://eased.dev/r/eased-number-roll.json"
  ],
  "files": [
    {
      "path": "src/registry/eased/like-button/like-button.tsx",
      "content": "\"use client\";\n\nimport { AnimatePresence, motion, useAnimate } from \"motion/react\";\nimport { useState, type ComponentProps } from \"react\";\nimport { NumberRoll } from \"../_primitives/number-roll/number-roll\";\nimport { bouncy, ease, snappy, useSafeReducedMotion } from \"../lib/motion\";\nimport { press } from \"../lib/press\";\n\nexport type LikeButtonProps = Omit<ComponentProps<\"button\">, \"onClick\" | \"children\" | \"type\"> & {\n  /** Liked (controlled). */\n  liked?: boolean;\n  /** Uncontrolled starting state. Default false. */\n  defaultLiked?: boolean;\n  /**\n   * Called with the next state. Return a promise to make the toggle optimistic: the heart and\n   * count change at once, and roll back (without a burst) if the promise rejects.\n   */\n  onLikedChange?: (liked: boolean) => void | Promise<unknown>;\n  /** The count before this viewer's like is applied. Leave it out to hide the count. */\n  count?: number;\n  /** Count formatting: `{ notation: \"compact\" }` turns 12,400 into 12K. */\n  format?: Intl.NumberFormatOptions;\n  locale?: Intl.LocalesArgument;\n  /** 32px (`sm`, default) or 36px (`md`). */\n  size?: \"sm\" | \"md\";\n  /** Accessible name. Default \"Like\". The count is read after it. */\n  \"aria-label\"?: string;\n  className?: string;\n};\n\nconst SIZES = {\n  sm: { box: \"h-control-sm gap-1.5 rounded-control-sm px-2 text-ui-sm\", icon: 16 },\n  md: { box: \"h-control-md gap-2 rounded-control px-2.5 text-ui\", icon: 18 },\n} as const;\n\n/** Eight sparks around the heart, alternating long and short, on a ring that is never round-robin. */\nconst SPARKS = Array.from({ length: 8 }, (_, i) => {\n  const angle = (i / 8) * Math.PI * 2 - Math.PI / 2 + (i % 2 ? 0.2 : 0);\n  return { angle, reach: i % 2 ? 13 : 17, size: i % 2 ? 3 : 4 };\n});\n\nconst HEART = \"M8 13.6s-5.6-3.3-5.6-7.3A3.1 3.1 0 0 1 8 4.4a3.1 3.1 0 0 1 5.6 1.9c0 4-5.6 7.3-5.6 7.3Z\";\n\n/**\n * A heart that pops and bursts when liked and deflates quietly when unliked, with a count\n * that rolls. Optimistic: the state flips at once and rolls back if `onLikedChange` rejects.\n */\nexport function LikeButton({\n  liked: controlled,\n  defaultLiked = false,\n  onLikedChange,\n  count,\n  format,\n  locale,\n  size = \"sm\",\n  \"aria-label\": ariaLabel = \"Like\",\n  className = \"\",\n  disabled,\n  ...rest\n}: LikeButtonProps) {\n  const [own, setOwn] = useState(defaultLiked);\n  const liked = controlled ?? own;\n  const reduced = useSafeReducedMotion();\n  const [scope, animate] = useAnimate<HTMLSpanElement>();\n  const [burst, setBurst] = useState(0);\n  // The count passed in includes or excludes this viewer's like as it was at mount.\n  const [likedAtMount] = useState(liked);\n  const shown = count === undefined ? undefined : count + (liked === likedAtMount ? 0 : liked ? 1 : -1);\n  const s = SIZES[size];\n\n  const pop = (next: boolean) => {\n    if (reduced) return;\n    if (next) {\n      // Up fast to 1.25, then back on the bouncy spring: the pop. Interruptible either way.\n      animate(scope.current, { scale: 1.25 }, { duration: 0.12, ease: ease.enter }).then(() =>\n        animate(scope.current, { scale: 1 }, bouncy),\n      );\n      setBurst((b) => b + 1);\n    } else {\n      animate(scope.current, { scale: 0.86 }, { duration: 0.1, ease: ease.standard }).then(() =>\n        animate(scope.current, { scale: 1 }, snappy),\n      );\n    }\n  };\n\n  const set = (next: boolean) => {\n    if (controlled === undefined) setOwn(next);\n  };\n\n  const toggle = () => {\n    const next = !liked;\n    set(next);\n    pop(next);\n    const result = onLikedChange?.(next);\n    if (result && typeof (result as Promise<unknown>).then === \"function\") {\n      (result as Promise<unknown>).catch(() => {\n        set(!next);\n        if (!reduced) animate(scope.current, { scale: [0.9, 1] }, snappy);\n      });\n    }\n  };\n\n  return (\n    <button\n      type=\"button\"\n      {...rest}\n      disabled={disabled}\n      aria-pressed={liked}\n      aria-label={shown === undefined ? ariaLabel : `${ariaLabel}, ${new Intl.NumberFormat(locale, format).format(shown)}`}\n      onClick={toggle}\n      data-liked={liked || undefined}\n      className={`group relative inline-flex shrink-0 cursor-pointer items-center font-medium whitespace-nowrap tabular-nums select-none disabled:cursor-not-allowed disabled:opacity-50 ${\n        liked ? \"text-(--like,var(--eased-danger-text))\" : \"text-text-muted hover-fine:text-text\"\n      } hover-fine:bg-surface-hover active:bg-surface-active ${press} ${s.box} ${className}`}\n    >\n      <span className=\"relative inline-flex shrink-0\" style={{ width: s.icon, height: s.icon }}>\n        {/* The burst: a ring that opens and fades, and eight sparks thrown outward. */}\n        <AnimatePresence>\n          {burst > 0 && liked && !reduced && (\n            <motion.span key={burst} aria-hidden className=\"pointer-events-none absolute inset-0\" exit={{ opacity: 0, transition: { duration: 0 } }}>\n              <motion.span\n                className=\"absolute inset-0 rounded-pill border-2 border-(--like,var(--eased-danger))\"\n                initial={{ scale: 0.3, opacity: 0.9 }}\n                animate={{ scale: 1.9, opacity: 0, borderWidth: 0 }}\n                transition={{ duration: 0.45, ease: ease.enter }}\n              />\n              {SPARKS.map((p, i) => (\n                <motion.span\n                  key={i}\n                  className={`absolute top-1/2 left-1/2 rounded-pill ${i % 2 ? \"bg-(--like-alt,var(--eased-warning))\" : \"bg-(--like,var(--eased-danger))\"}`}\n                  style={{ width: p.size, height: p.size, marginLeft: -p.size / 2, marginTop: -p.size / 2 }}\n                  initial={{ x: Math.cos(p.angle) * 6, y: Math.sin(p.angle) * 6, scale: 1, opacity: 1 }}\n                  animate={{ x: Math.cos(p.angle) * p.reach, y: Math.sin(p.angle) * p.reach, scale: 0, opacity: 0 }}\n                  transition={{ duration: 0.5, ease: ease.enter, delay: 0.06 }}\n                />\n              ))}\n            </motion.span>\n          )}\n        </AnimatePresence>\n        <span ref={scope} className=\"relative inline-flex size-full\">\n          <svg viewBox=\"0 0 16 16\" className=\"size-full\" aria-hidden fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.5\" strokeLinejoin=\"round\">\n            <path d={HEART} />\n          </svg>\n          {/* The fill grows from the heart's centre when liked, and shrinks back when not. */}\n          <motion.svg\n            viewBox=\"0 0 16 16\"\n            className=\"absolute inset-0 size-full\"\n            aria-hidden\n            initial={false}\n            animate={liked ? { scale: 1, opacity: 1 } : { scale: 0.4, opacity: 0 }}\n            transition={reduced ? { duration: 0 } : liked ? snappy : { duration: 0.12, ease: ease.exit }}\n          >\n            <path d={HEART} fill=\"currentColor\" stroke=\"currentColor\" strokeWidth=\"1.5\" strokeLinejoin=\"round\" />\n          </motion.svg>\n        </span>\n      </span>\n      {/* At least two digits wide, so hearts in a column of rows line up. */}\n      {shown !== undefined && (\n        <span className=\"inline-flex min-w-[2ch]\">\n          <NumberRoll value={shown} format={format} locale={locale} aria-hidden />\n        </span>\n      )}\n    </button>\n  );\n}\n",
      "type": "registry:ui",
      "target": "@components/eased/like-button/like-button.tsx"
    }
  ],
  "meta": {
    "details": "A heart that pops to 1.25 with a ring and eight sparks when liked, deflates quietly when unliked, and rolls its count. Optimistic: it flips at once and rolls back if onLikedChange rejects.",
    "industries": [
      "media",
      "ecommerce",
      "education",
      "saas",
      "travel"
    ],
    "releaseAt": "2026-10-26"
  },
  "docs": "Import LikeButton from components/eased/like-button/like-button. The Eased tokens were merged into your global CSS; nothing to import. Dark mode follows .dark or data-theme=\"dark\".",
  "type": "registry:ui"
}