{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"like-burst","type":"registry:ui","title":"Like Burst","description":"Optimistic like that survives rapid taps.","dependencies":["motion"],"categories":["action feedback"],"docs":"https://www.interior.dev/docs/like-burst","files":[{"path":"registry/interior/like-burst.tsx","type":"registry:ui","target":"components/interior/like-burst.tsx","content":"\"use client\";\n\nimport {\n  useCallback,\n  useEffect,\n  useImperativeHandle,\n  useRef,\n  useState,\n} from \"react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\n\nconst EASE = [0.23, 1, 0.32, 1] as const;\nconst CELL = { type: \"spring\", stiffness: 520, damping: 34, mass: 0.45 } as const;\nconst CROSSFADE = { type: \"spring\", stiffness: 260, damping: 34, mass: 0.8 } as const;\nconst INSTANT = { duration: 0 } as const;\n\nconst HEART =\n  \"M12 20.3 4.3 12.6a4.8 4.8 0 0 1 6.8-6.8l.9.9.9-.9a4.8 4.8 0 0 1 6.8 6.8Z\";\n\nconst SPARKS = Array.from({ length: 8 }, (_, i) => {\n  const h = (((i + 1) * 2654435761) % 997) / 997;\n  const angle = (i / 8) * Math.PI * 2 - Math.PI / 2 + (h - 0.5) * 0.4;\n  const distance = 13 + h * 9;\n  return {\n    x: Math.round(Math.cos(angle) * distance * 10) / 10,\n    y: Math.round(Math.sin(angle) * distance * 10) / 10,\n    size: h > 0.5 ? 4 : 3,\n    delay: Math.round(h * 50) / 1000,\n  };\n});\n\nconst DEFAULT_FORMAT = (value: number) =>\n  new Intl.NumberFormat(\"en-US\").format(value);\n\nexport type LikeCommit = (\n  liked: boolean,\n  signal: AbortSignal,\n) => Promise<unknown>;\n\nexport type LikeBurstHandle = {\n  toggle: () => void;\n};\n\nexport type UseOptimisticLikeOptions = {\n  initialLiked?: boolean;\n  initialCount?: number;\n  onCommit?: LikeCommit;\n  onError?: (error: unknown) => void;\n  settle?: number;\n};\n\nexport type OptimisticLike = {\n  liked: boolean;\n  count: number;\n  base: number;\n  pending: boolean;\n  burst: number;\n  settled: { liked: boolean; count: number };\n  toggle: () => void;\n};\n\nexport function useOptimisticLike({\n  initialLiked = false,\n  initialCount = 0,\n  onCommit,\n  onError,\n  settle = 400,\n}: UseOptimisticLikeOptions = {}): OptimisticLike {\n  const [liked, setLiked] = useState(initialLiked);\n  const [count, setCount] = useState(initialCount);\n  const [pending, setPending] = useState(false);\n  const [burst, setBurst] = useState(0);\n  const [settled, setSettled] = useState({\n    liked: initialLiked,\n    count: initialCount,\n  });\n\n  const likedNow = useRef(initialLiked);\n  const countNow = useRef(initialCount);\n  const truth = useRef({ liked: initialLiked, count: initialCount });\n  const timer = useRef<ReturnType<typeof setTimeout> | null>(null);\n  const inFlight = useRef<AbortController | null>(null);\n  const seq = useRef(0);\n\n  const commit = useRef(onCommit);\n  commit.current = onCommit;\n  const failed = useRef(onError);\n  failed.current = onError;\n\n  const flush = useCallback(() => {\n    timer.current = null;\n    inFlight.current?.abort();\n    inFlight.current = null;\n    seq.current += 1;\n\n    const intent = likedNow.current;\n\n    if (intent === truth.current.liked) {\n      countNow.current = truth.current.count;\n      setLiked(truth.current.liked);\n      setCount(truth.current.count);\n      setPending(false);\n      return;\n    }\n\n    const target = { liked: intent, count: countNow.current };\n    const run = commit.current;\n\n    if (!run) {\n      truth.current = target;\n      setSettled(target);\n      setPending(false);\n      return;\n    }\n\n    const controller = new AbortController();\n    const id = seq.current;\n    inFlight.current = controller;\n    setPending(true);\n\n    run(intent, controller.signal).then(\n      () => {\n        if (id !== seq.current) return;\n        inFlight.current = null;\n        truth.current = target;\n        setSettled(target);\n        setPending(false);\n      },\n      (error: unknown) => {\n        if (id !== seq.current) return;\n        inFlight.current = null;\n        likedNow.current = truth.current.liked;\n        countNow.current = truth.current.count;\n        setLiked(truth.current.liked);\n        setCount(truth.current.count);\n        setPending(false);\n        failed.current?.(error);\n      },\n    );\n  }, []);\n\n  const toggle = useCallback(() => {\n    const next = !likedNow.current;\n    likedNow.current = next;\n    countNow.current += next ? 1 : -1;\n\n    setLiked(next);\n    setCount(countNow.current);\n    setPending(true);\n    if (next) setBurst((b) => b + 1);\n\n    if (timer.current) clearTimeout(timer.current);\n    timer.current = setTimeout(flush, settle);\n  }, [flush, settle]);\n\n  useEffect(\n    () => () => {\n      if (timer.current) clearTimeout(timer.current);\n      timer.current = null;\n      seq.current += 1;\n      inFlight.current?.abort();\n      inFlight.current = null;\n    },\n    [],\n  );\n\n  return {\n    liked,\n    count,\n    base: liked ? count - 1 : count,\n    pending,\n    burst,\n    settled,\n    toggle,\n  };\n}\n\nexport type LikeBurstProps = {\n  initialLiked?: boolean;\n  initialCount?: number;\n  onCommit?: LikeCommit;\n  onError?: (error: unknown) => void;\n  onToggle?: (liked: boolean) => void;\n  settle?: number;\n  label?: string;\n  activeLabel?: string;\n  format?: (value: number) => string;\n  disabled?: boolean;\n  className?: string;\n};\n\nexport function LikeBurst({\n  initialLiked = false,\n  initialCount = 0,\n  onCommit,\n  onError,\n  onToggle,\n  settle = 400,\n  label = \"Like\",\n  activeLabel = \"Liked\",\n  format = DEFAULT_FORMAT,\n  disabled = false,\n  className = \"\",\n  ref,\n}: LikeBurstProps & { ref?: React.Ref<LikeBurstHandle> }) {\n  const reduced = useReducedMotion();\n  const { liked, count, base, pending, burst, settled, toggle } =\n    useOptimisticLike({ initialLiked, initialCount, onCommit, onError, settle });\n\n  useImperativeHandle(ref, () => ({ toggle }), [toggle]);\n\n  const low = format(base);\n  const high = format(base + 1);\n  const widest = high.length >= low.length ? high : low;\n  const shown = format(count);\n\n  return (\n    <span className={`inline-flex items-center ${className}`}>\n      <button\n        type=\"button\"\n        disabled={disabled}\n        aria-pressed={liked}\n        aria-busy={pending}\n        aria-label={label}\n        onClick={() => {\n          toggle();\n          onToggle?.(!liked);\n        }}\n        style={{ touchAction: \"manipulation\" }}\n        className=\"inline-flex h-9 select-none items-center gap-2 rounded-[9px] border border-stone-200 bg-white px-3 text-[13px] font-medium text-stone-700 outline-none focus-visible:ring-2 focus-visible:ring-stone-400 disabled:opacity-50 dark:border-white/[0.16] dark:bg-[#1D1D1A] dark:text-stone-200 dark:focus-visible:ring-stone-500\"\n      >\n        <span aria-hidden className=\"relative block size-[18px]\">\n          <motion.svg\n            viewBox=\"0 0 24 24\"\n            fill=\"none\"\n            stroke=\"currentColor\"\n            strokeWidth={1.7}\n            strokeLinejoin=\"round\"\n            className=\"absolute inset-0 size-[18px] text-stone-500 dark:text-stone-400\"\n            initial={false}\n            animate={{ opacity: liked ? 0 : 1 }}\n            transition={reduced ? INSTANT : CROSSFADE}\n          >\n            <path d={HEART} />\n          </motion.svg>\n\n          <motion.svg\n            viewBox=\"0 0 24 24\"\n            fill=\"currentColor\"\n            className=\"absolute inset-0 size-[18px] text-stone-800 dark:text-stone-100\"\n            initial={false}\n            animate={{ opacity: liked ? 1 : 0, scale: liked ? 1 : 0.55 }}\n            transition={reduced ? INSTANT : CELL}\n          >\n            <path d={HEART} />\n          </motion.svg>\n\n          {!reduced && burst > 0 ? (\n            <span\n              key={burst}\n              className=\"pointer-events-none absolute left-1/2 top-1/2 block size-0\"\n            >\n              {SPARKS.map((spark, i) => (\n                <motion.span\n                  key={i}\n                  className=\"absolute block rounded-[1.5px] bg-stone-800 dark:bg-stone-100\"\n                  style={{\n                    width: spark.size,\n                    height: spark.size,\n                    marginLeft: -spark.size / 2,\n                    marginTop: -spark.size / 2,\n                  }}\n                  initial={{ x: 0, y: 0, scale: 0.6, opacity: 0.85 }}\n                  animate={{ x: spark.x, y: spark.y, scale: 1, opacity: 0 }}\n                  transition={{ duration: 0.44, delay: spark.delay, ease: EASE }}\n                />\n              ))}\n            </span>\n          ) : null}\n        </span>\n\n        <span aria-hidden className=\"grid\">\n          <motion.span\n            className=\"col-start-1 row-start-1\"\n            initial={false}\n            animate={{ opacity: liked ? 0 : 1 }}\n            transition={reduced ? INSTANT : CROSSFADE}\n          >\n            {label}\n          </motion.span>\n          <motion.span\n            className=\"col-start-1 row-start-1\"\n            initial={false}\n            animate={{ opacity: liked ? 1 : 0 }}\n            transition={reduced ? INSTANT : CROSSFADE}\n          >\n            {activeLabel}\n          </motion.span>\n        </span>\n\n        <span\n          aria-hidden\n          className=\"grid overflow-hidden text-[12px] tabular-nums text-stone-500 dark:text-stone-400\"\n        >\n          <span className=\"invisible col-start-1 row-start-1\">{widest}</span>\n          <AnimatePresence initial={false}>\n            <motion.span\n              key={shown}\n              className=\"col-start-1 row-start-1 justify-self-end\"\n              initial={{ opacity: 0, y: reduced ? 0 : -7 }}\n              animate={{ opacity: 1, y: 0 }}\n              exit={{ opacity: 0, y: reduced ? 0 : 7 }}\n              transition={reduced ? INSTANT : CROSSFADE}\n            >\n              {shown}\n            </motion.span>\n          </AnimatePresence>\n        </span>\n\n      </button>\n\n      <span role=\"status\" aria-live=\"polite\" className=\"sr-only\">\n        {`${format(settled.count)} likes, ${settled.liked ? \"liked\" : \"not liked\"}`}\n      </span>\n    </span>\n  );\n}\n"}]}