{
	"$schema": "https://ui.shadcn.com/schema/registry-item.json",
	"name": "PulseHeart-JS-TW",
	"title": "PulseHeart",
	"description": "Like button that contracts to a dot, flips colour and pulses back as the count ticks over.",
	"type": "registry:component",
	"files": [
		{
			"type": "registry:component",
			"path": "PulseHeart/PulseHeart.jsx",
			"content": "'use client';\n\nimport { useEffect, useLayoutEffect, useRef, useState } from 'react';\nimport { FavouriteIcon, StarIcon, ThumbsUpIcon } from '@hugeicons/core-free-icons';\n\nconst OUT = 0.4;\nconst ICONS = { heart: FavouriteIcon, star: StarIcon, thumb: ThumbsUpIcon };\n\nconst back = (k, c) => {\n  const u = k - 1;\n  return 1 + (c + 1) * u ** 3 + c * u ** 2;\n};\nconst swellOf = (t, c) => (t <= 0 ? 0 : t < OUT ? 1 - (1 - t / OUT) ** 3 : 1 - back((t - OUT) / (1 - OUT), c));\nconst format = n => new Intl.NumberFormat().format(n);\nconst reducedMotion = () =>\n  typeof window !== 'undefined' && !!window.matchMedia?.('(prefers-reduced-motion: reduce)').matches;\n\nexport default function PulseHeart({\n  liked: likedProp,\n  defaultLiked = false,\n  count = 0,\n  onChange,\n  showCount = true,\n  icon = 'heart',\n  idleOutline = true,\n  size = 40,\n  corner = 32,\n  likedColor = '#ff4d6d',\n  idleColor = '#8b8b93',\n  pillColor = '#232326',\n  textColor = '#f5f5f5',\n  duration = 560,\n  dotSize = 0.3,\n  overshoot = 1.7,\n  beat = 3,\n  rollDuration = 350,\n  disabled = false,\n  label = 'Like',\n  className = ''\n}) {\n  const controlled = likedProp !== undefined;\n  const [inner, setInner] = useState(defaultLiked);\n  const [total, setTotal] = useState(count);\n  const liked = controlled ? likedProp : inner;\n  const [shown, setShown] = useState({ liked, count });\n  const [roll, setRoll] = useState(null);\n\n  const rootRef = useRef(null);\n  const pillRef = useRef(null);\n  const heartRef = useRef(null);\n  const glyphRef = useRef(null);\n  const rollRef = useRef(null);\n  const raf = useRef(0);\n  const rollTimer = useRef(0);\n  const viaPointer = useRef(false);\n  const shownRef = useRef(shown);\n  const logical = useRef({ liked, count: total });\n  logical.current = { liked, count: total };\n  const cfg = useRef({ duration, dotSize, overshoot, beat, rollDuration });\n  cfg.current = { duration, dotSize, overshoot, beat, rollDuration };\n\n  useEffect(() => {\n    setTotal(count);\n  }, [count]);\n\n  useEffect(() => {\n    if (raf.current) return;\n    if (shownRef.current.liked === liked && shownRef.current.count === total) return;\n    shownRef.current = { liked, count: total };\n    setShown(shownRef.current);\n  }, [liked, total]);\n\n  useLayoutEffect(() => {\n    const root = rootRef.current;\n    if (!root || root.dataset.instant === undefined) return;\n    root.getBoundingClientRect();\n    delete root.dataset.instant;\n  }, [shown]);\n\n  useLayoutEffect(() => {\n    const el = rollRef.current;\n    if (!el || !roll) return;\n    el.style.transition = 'none';\n    el.style.transform = `translateY(${roll.up ? '0' : '-1em'})`;\n    el.getBoundingClientRect();\n    el.style.transition = '';\n    el.style.transform = `translateY(${roll.up ? '-1em' : '0'})`;\n  }, [roll]);\n\n  useEffect(\n    () => () => {\n      cancelAnimationFrame(raf.current);\n      clearTimeout(rollTimer.current);\n    },\n    []\n  );\n\n  const startRoll = (from, to) => {\n    if (from === to) return;\n    const a = format(from);\n    const b = format(to);\n    const changed = a.length === b.length ? [...b].flatMap((ch, i) => (ch !== a[i] ? [i] : [])) : [];\n    setRoll({ a, b, at: changed.length === 1 ? changed[0] : -1, up: to > from });\n    clearTimeout(rollTimer.current);\n    rollTimer.current = setTimeout(() => setRoll(null), cfg.current.rollDuration);\n  };\n\n  const run = (nextLiked, nextCount) => {\n    const root = rootRef.current;\n    const heart = heartRef.current;\n    const pill = pillRef.current;\n    if (!root || !heart || !pill) return;\n    const glyph = glyphRef.current;\n    root.dataset.running = '';\n    let swapped = false;\n    let prev = 0;\n    const t0 = performance.now();\n    const tick = now => {\n      const { duration: D, dotSize: dot, overshoot: c, beat: B } = cfg.current;\n      const t = Math.min(1, (now - t0) / D);\n      const step = prev ? now - prev : 1000 / 60;\n      prev = now;\n      const s = swellOf(t, c);\n      const k = 1 - (1 - dot) * s;\n      if (glyph) glyph.setAttribute('transform', `translate(12 12) scale(${k}) translate(-12 -12)`);\n      else heart.style.transform = `scale(${k})`;\n      pill.style.transform = `scale(${1 - (B / 100) * s})`;\n      if (!swapped && t + step / 2 / D >= OUT) {\n        swapped = true;\n        root.dataset.liked = String(nextLiked);\n        startRoll(shownRef.current.count, nextCount);\n        shownRef.current = { liked: nextLiked, count: nextCount };\n        setShown(shownRef.current);\n      }\n      if (t < 1) {\n        raf.current = requestAnimationFrame(tick);\n        return;\n      }\n      raf.current = 0;\n      if (glyph) glyph.removeAttribute('transform');\n      heart.style.transform = '';\n      pill.style.transform = '';\n      delete root.dataset.running;\n      const l = logical.current;\n      if (l.liked !== shownRef.current.liked || l.count !== shownRef.current.count) {\n        shownRef.current = { liked: l.liked, count: l.count };\n        setShown(shownRef.current);\n      }\n    };\n    raf.current = requestAnimationFrame(tick);\n  };\n\n  const handlePointerDown = e => {\n    if (e.button !== 0 || disabled) return;\n    viaPointer.current = true;\n    if (!reducedMotion() && rootRef.current) rootRef.current.dataset.pressed = '';\n  };\n  const handlePointerUp = () => {\n    if (rootRef.current) delete rootRef.current.dataset.pressed;\n  };\n  const handlePointerCancel = () => {\n    viaPointer.current = false;\n    handlePointerUp();\n  };\n  const handleKeyDown = () => {\n    viaPointer.current = false;\n  };\n  const handleClick = e => {\n    if (disabled || raf.current) return;\n    const pointer = viaPointer.current && e.detail !== 0;\n    viaPointer.current = false;\n    const nextLiked = !liked;\n    const nextCount = total + (nextLiked ? 1 : -1);\n    if (!controlled) setInner(nextLiked);\n    setTotal(nextCount);\n    onChange?.(nextLiked, nextCount);\n    if (pointer && !reducedMotion()) run(nextLiked, nextCount);\n    else if (rootRef.current) rootRef.current.dataset.instant = '';\n  };\n\n  const paths = typeof icon === 'string' ? ICONS[icon] || ICONS.heart : null;\n  const text = format(shown.count);\n  const cells = roll\n    ? roll.at === -1\n      ? [{ top: roll.up ? roll.a : roll.b, bottom: roll.up ? roll.b : roll.a }]\n      : [...roll.b].map((ch, i) =>\n          i === roll.at ? { top: roll.up ? roll.a[i] : ch, bottom: roll.up ? ch : roll.a[i] } : { ch }\n        )\n    : [...text].map(ch => ({ ch }));\n\n  return (\n    <button\n      ref={rootRef}\n      type=\"button\"\n      aria-pressed={liked}\n      disabled={disabled}\n      data-liked={String(shown.liked)}\n      data-solid={idleOutline ? undefined : ''}\n      data-no-count={showCount ? undefined : ''}\n      className={`group relative m-0 inline-flex cursor-pointer touch-manipulation select-none items-center justify-center border-0 bg-transparent p-0 outline-none [font-family:inherit] [color:var(--ph-text)] rounded-[var(--ph-corner)] [-webkit-tap-highlight-color:transparent] [-webkit-touch-callout:none] [transition:transform_160ms_var(--ph-ease-out)] data-[pressed]:[transform:scale(0.97)] focus-visible:outline-offset-[3px] focus-visible:[outline:2px_solid_color-mix(in_srgb,var(--ph-liked)_60%,transparent)] disabled:cursor-default disabled:opacity-[0.55] motion-reduce:transition-none [@media(pointer:coarse)]:min-h-11 [@media(pointer:coarse)]:min-w-11${className ? ` ${className}` : ''}`}\n      style={{\n        '--ph-size': `${size}px`,\n        '--ph-corner': `${corner}px`,\n        '--ph-pill': pillColor,\n        '--ph-idle': idleColor,\n        '--ph-liked': likedColor,\n        '--ph-text': textColor,\n        '--ph-roll': `${rollDuration}ms`,\n        '--ph-stroke': `${(1.5 * size) / 24}px`,\n        '--ph-ease-out': 'cubic-bezier(0.23, 1, 0.32, 1)'\n      }}\n      onPointerDown={handlePointerDown}\n      onPointerUp={handlePointerUp}\n      onPointerLeave={handlePointerUp}\n      onPointerCancel={handlePointerCancel}\n      onKeyDown={handleKeyDown}\n      onClick={handleClick}\n    >\n      <span\n        ref={pillRef}\n        className=\"inline-flex origin-center items-center [gap:calc(var(--ph-size)*0.25)] [padding:calc(var(--ph-size)*0.3)] [padding-inline-end:calc(var(--ph-size)*0.4)] group-data-[no-count]:[padding-inline-end:calc(var(--ph-size)*0.3)] rounded-[var(--ph-corner)] [background:var(--ph-pill)] [@media(prefers-contrast:more)]:[box-shadow:inset_0_0_0_1px_color-mix(in_srgb,var(--ph-text)_25%,transparent)]\"\n      >\n        <span\n          ref={heartRef}\n          className=\"block h-[var(--ph-size)] w-[var(--ph-size)] origin-center [color:var(--ph-idle)] [transition:color_160ms_ease] group-data-[liked=true]:[color:var(--ph-liked)] group-data-[running]:transition-none group-data-[instant]:transition-none motion-reduce:[transition:color_200ms_ease] [&_svg]:block [&_svg]:h-full [&_svg]:w-full [&_svg]:overflow-visible [&_svg]:fill-none [&_svg]:stroke-current [&_svg]:[stroke-width:var(--ph-stroke)] [&_svg]:[stroke-linecap:round] [&_svg]:[stroke-linejoin:round] group-data-[liked=true]:[&_svg]:fill-current group-data-[solid]:[&_svg]:fill-current [@media(hover:hover)_and_(pointer:fine)]:group-enabled:group-hover:group-data-[liked=false]:[color:color-mix(in_srgb,var(--ph-idle)_45%,var(--ph-liked))]\"\n          aria-hidden=\"true\"\n        >\n          {paths ? (\n            <svg viewBox=\"0 0 24 24\" xmlns=\"http://www.w3.org/2000/svg\">\n              <g ref={glyphRef}>\n                {paths.map(([, attrs]) => (\n                  <path key={String(attrs.key)} d={String(attrs.d)} vectorEffect=\"non-scaling-stroke\" />\n                ))}\n              </g>\n            </svg>\n          ) : (\n            icon\n          )}\n        </span>\n        {showCount ? (\n          <span\n            className=\"inline-flex font-semibold leading-none tracking-[-0.1px] tabular-nums [font-size:calc(var(--ph-size)*0.5)] [color:var(--ph-text)] [&>span]:block [&>span]:h-[1em]\"\n            aria-hidden=\"true\"\n          >\n            {cells.map((cell, i) =>\n              'ch' in cell ? (\n                <span key={`c${i}`}>{cell.ch}</span>\n              ) : (\n                <span key={`r${i}`} className=\"relative overflow-hidden\">\n                  <span\n                    ref={rollRef}\n                    className=\"flex flex-col [transition:transform_var(--ph-roll)_var(--ph-ease-out)] motion-reduce:transition-none [&>span]:block [&>span]:h-[1em] [&>span]:leading-none\"\n                  >\n                    <span>{cell.top}</span>\n                    <span>{cell.bottom}</span>\n                  </span>\n                </span>\n              )\n            )}\n          </span>\n        ) : null}\n        <span className=\"sr-only\">{showCount ? `${label}, ${format(total)}` : label}</span>\n      </span>\n    </button>\n  );\n}\n"
		}
	],
	"registryDependencies": [],
	"dependencies": [
		"@hugeicons/core-free-icons@^4.3.3"
	]
}