{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "like-button",
  "type": "registry:ui",
  "title": "Like Button",
  "description": "Optimistic like with a squash-and-stretch heart, rolling count and honest rollback.",
  "categories": [
    "actions"
  ],
  "dependencies": [
    "@base-ui/react",
    "@number-flow/react",
    "motion"
  ],
  "registryDependencies": [
    "https://www.stealth.pm/r/tokens.json",
    "https://www.stealth.pm/r/cn.json",
    "https://www.stealth.pm/r/motion.json",
    "https://www.stealth.pm/r/use-controllable-state.json"
  ],
  "files": [
    {
      "path": "components/ui/like-button.tsx",
      "content": "\"use client\";\nimport { Toggle } from \"@base-ui/react/toggle\";\nimport NumberFlow from \"@number-flow/react\";\nimport { AnimatePresence, motion, useAnimate, useReducedMotion } from \"motion/react\";\nimport { useEffect, useId, useRef, useState } from \"react\";\nimport { cn } from \"@/lib/cn\";\nimport { ease, spring } from \"@/lib/motion\";\nimport { useControllableState } from \"@/lib/use-controllable-state\";\n\ntype Save = (liked: boolean) => unknown;\n\n/**\n * Optimistic like state with one request in flight at a time. Rapid toggles\n * settle on the last intent and send at most one follow-up; a failure puts the\n * interface back to the last state the server agreed with.\n */\nexport function useOptimisticLike({\n  liked: likedProp,\n  defaultLiked = false,\n  onLikedChange,\n  onSave,\n  onError,\n}: {\n  liked?: boolean;\n  defaultLiked?: boolean;\n  onLikedChange?: (liked: boolean) => void;\n  onSave?: Save;\n  onError?: (error: unknown) => void;\n}) {\n  const [liked, setLiked] = useControllableState({ value: likedProp, defaultValue: defaultLiked, onChange: onLikedChange });\n  const [failed, setFailed] = useState(false);\n  const confirmed = useRef(liked);\n  const desired = useRef(liked);\n  const inflight = useRef(false);\n  const alive = useRef(true);\n  const timer = useRef<number>(undefined);\n\n  useEffect(() => {\n    alive.current = true;\n    return () => {\n      alive.current = false;\n      window.clearTimeout(timer.current);\n    };\n  }, []);\n\n  const flush = async () => {\n    if (!onSave) {\n      confirmed.current = desired.current;\n      return;\n    }\n    inflight.current = true;\n    const target = desired.current;\n    try {\n      await onSave(target);\n      confirmed.current = target;\n    } catch (error) {\n      inflight.current = false;\n      if (!alive.current) return;\n      desired.current = confirmed.current;\n      setLiked(confirmed.current);\n      setFailed(true);\n      window.clearTimeout(timer.current);\n      timer.current = window.setTimeout(() => setFailed(false), 2400);\n      onError?.(error);\n      return;\n    }\n    inflight.current = false;\n    // Toggled again while we were waiting: send the latest intent, once.\n    if (alive.current && desired.current !== confirmed.current) flush();\n  };\n\n  const toggle = (next: boolean) => {\n    desired.current = next;\n    setLiked(next);\n    setFailed(false);\n    if (!inflight.current) flush();\n  };\n\n  return { liked, toggle, failed };\n}\n\nexport type LikeButtonProps = Omit<React.ComponentProps<\"button\">, \"value\" | \"onChange\" | \"defaultValue\"> & {\n  /** Total likes when the page loaded, including the viewer's own if they had liked it. */\n  count?: number;\n  liked?: boolean;\n  defaultLiked?: boolean;\n  /** Fires on every change, including a rollback after a failed save. */\n  onLikedChange?: (liked: boolean) => void;\n  /** Persist the change. Return a promise; if it rejects the like is rolled back. */\n  onSave?: Save;\n  onError?: (error: unknown) => void;\n  /** Accessible name. The pressed state says whether it is on. */\n  label?: string;\n  /** How the count is read out. */\n  countLabel?: (count: number) => string;\n  hideCount?: boolean;\n  size?: \"sm\" | \"md\";\n  locales?: Intl.LocalesArgument;\n};\n\nconst DOTS = 7;\n\nexport function LikeButton({\n  count = 0,\n  liked: likedProp,\n  defaultLiked = false,\n  onLikedChange,\n  onSave,\n  onError,\n  label = \"Like\",\n  countLabel = (n) => (n === 1 ? \"1 like\" : `${n.toLocaleString(\"en-US\")} likes`),\n  hideCount = false,\n  size = \"md\",\n  locales = \"en-US\",\n  disabled,\n  className,\n  ref,\n  ...rest\n}: LikeButtonProps) {\n  const { liked, toggle, failed } = useOptimisticLike({ liked: likedProp, defaultLiked, onLikedChange, onSave, onError });\n  const [initial] = useState(liked);\n  const [burst, setBurst] = useState(0);\n  const [heart, animateHeart] = useAnimate<HTMLSpanElement>();\n  const reduce = useReducedMotion();\n  const countId = useId();\n  const total = Math.max(0, count - (initial ? 1 : 0) + (liked ? 1 : 0));\n  const icon = size === \"sm\" ? 14 : 16;\n\n  return (\n    <>\n      <Toggle\n        ref={ref as React.Ref<HTMLButtonElement>}\n        pressed={liked}\n        disabled={disabled}\n        aria-label={label}\n        aria-describedby={hideCount ? undefined : countId}\n        onPressedChange={(next) => {\n          toggle(next);\n          if (next && !reduce) {\n            // Squash, stretch, settle: the heart takes the press like something soft.\n            animateHeart(\n              heart.current,\n              { scaleX: [1, 1.22, 0.9, 1.04, 1], scaleY: [1, 0.78, 1.14, 0.98, 1] },\n              { duration: 0.46, ease: ease.out, times: [0, 0.18, 0.46, 0.74, 1] },\n            );\n            setBurst((b) => b + 1);\n          }\n        }}\n        data-size={size}\n        data-failed={failed || undefined}\n        className={cn(\n          \"group/like relative inline-flex shrink-0 select-none items-center rounded-full font-medium tracking-[-0.005em] text-fg-2\",\n          \"outline-none focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3\",\n          \"transition-[background-color,color,scale] duration-150 ease-out hover:bg-hover hover:text-fg active:scale-[0.94] active:duration-75\",\n          \"data-[pressed]:text-fg\",\n          // Stretch the hit area to 44px on touch without changing the drawing.\n          \"before:absolute before:-inset-y-2 before:inset-x-0 before:content-[''] pointer-fine:before:hidden\",\n          size === \"sm\" ? \"h-7 gap-1 px-2 text-[12px]\" : \"h-8 gap-1.5 px-2.5 text-[12.5px]\",\n          hideCount && (size === \"sm\" ? \"w-7 justify-center px-0\" : \"w-8 justify-center px-0\"),\n          disabled && \"pointer-events-none opacity-50\",\n          className,\n        )}\n        {...rest}\n      >\n        <span className=\"relative grid place-items-center\" style={{ width: icon, height: icon }}>\n          {!reduce && burst > 0 && <Burst key={burst} />}\n          <span ref={heart} className=\"relative grid place-items-center\">\n            <svg width={icon} height={icon} viewBox=\"0 0 16 16\" fill=\"none\" aria-hidden className={cn(\"transition-colors duration-200\", failed ? \"text-danger\" : \"\")}>\n              <path\n                d=\"M8 13.25S2.25 10 2.25 6.1A2.85 2.85 0 0 1 8 4.9a2.85 2.85 0 0 1 5.75 1.2C13.75 10 8 13.25 8 13.25z\"\n                stroke=\"currentColor\"\n                strokeWidth={1.4}\n                strokeLinejoin=\"round\"\n                className={cn(\"transition-opacity duration-150\", liked && \"opacity-0\")}\n              />\n            </svg>\n            {/* The fill grows from the center of the outline rather than switching on. */}\n            <AnimatePresence initial={false}>\n              {liked && (\n                <motion.svg\n                  key=\"fill\"\n                  width={icon}\n                  height={icon}\n                  viewBox=\"0 0 16 16\"\n                  aria-hidden\n                  className=\"absolute inset-0 text-danger\"\n                  initial={reduce ? { opacity: 0 } : { scale: 0.3, opacity: 0 }}\n                  animate={{ scale: 1, opacity: 1 }}\n                  exit={reduce ? { opacity: 0 } : { scale: 0.5, opacity: 0, transition: { duration: 0.12, ease: ease.in } }}\n                  transition={reduce ? { duration: 0.15 } : spring.pop}\n                >\n                  <path\n                    d=\"M8 13.25S2.25 10 2.25 6.1A2.85 2.85 0 0 1 8 4.9a2.85 2.85 0 0 1 5.75 1.2C13.75 10 8 13.25 8 13.25z\"\n                    fill=\"currentColor\"\n                    stroke=\"currentColor\"\n                    strokeWidth={1.4}\n                    strokeLinejoin=\"round\"\n                  />\n                </motion.svg>\n              )}\n            </AnimatePresence>\n          </span>\n        </span>\n        {!hideCount && (\n          <span aria-hidden className={cn(\"tabular transition-colors duration-200\", failed && \"text-danger\")}>\n            <NumberFlow\n              value={total}\n              locales={locales}\n              format={{ notation: total >= 10_000 ? \"compact\" : \"standard\", maximumFractionDigits: 1 }}\n              transformTiming={{ duration: 420, easing: \"cubic-bezier(0.16, 1, 0.3, 1)\" }}\n              spinTiming={{ duration: 420, easing: \"cubic-bezier(0.16, 1, 0.3, 1)\" }}\n            />\n          </span>\n        )}\n      </Toggle>\n      {!hideCount && (\n        <span id={countId} className=\"sr-only\">\n          {countLabel(total)}\n        </span>\n      )}\n      <span role=\"status\" aria-live=\"polite\" className=\"sr-only\">\n        {failed ? \"Couldn’t save. Try again.\" : \"\"}\n      </span>\n    </>\n  );\n}\n\n// Seven dots thrown out from under the heart, once per like. Small and quick,\n// so it reads as the heart landing rather than a celebration.\nfunction Burst() {\n  return (\n    <span aria-hidden className=\"pointer-events-none absolute inset-0\">\n      <motion.span\n        className=\"absolute inset-0 rounded-full border border-danger\"\n        initial={{ scale: 0.4, opacity: 0.5 }}\n        animate={{ scale: 1.9, opacity: 0 }}\n        transition={{ duration: 0.42, ease: ease.out }}\n      />\n      {Array.from({ length: DOTS }, (_, i) => {\n        const a = (i / DOTS) * Math.PI * 2 - Math.PI / 2;\n        const r = 13 + (i % 2) * 2;\n        return (\n          <motion.span\n            key={i}\n            className={cn(\"absolute left-1/2 top-1/2 -ml-[1.5px] -mt-[1.5px] size-[3px] rounded-full\", i % 2 ? \"bg-danger/60\" : \"bg-danger\")}\n            initial={{ x: 0, y: 0, scale: 0.6, opacity: 0 }}\n            animate={{ x: Math.cos(a) * r, y: Math.sin(a) * r, scale: [0.6, 1, 0], opacity: [0, 1, 0] }}\n            transition={{ duration: 0.5, ease: ease.out, delay: 0.04, times: [0, 0.35, 1] }}\n          />\n        );\n      })}\n    </span>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/like-button"
}
