{
	"$schema": "https://ui.shadcn.com/schema/registry-item.json",
	"name": "PulseHeart-TS-CSS",
	"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:file",
			"path": "PulseHeart.css",
			"target": "@components/PulseHeart.css",
			"content": ".pulse-heart {\n  --ph-size: 40px;\n  --ph-corner: 32px;\n  --ph-pill: #232326;\n  --ph-idle: #8b8b93;\n  --ph-liked: #ff4d6d;\n  --ph-text: #f5f5f5;\n  --ph-roll: 350ms;\n  --ph-stroke: 2.5px;\n  --ph-ease-out: cubic-bezier(0.23, 1, 0.32, 1);\n\n  position: relative;\n  display: inline-flex;\n  align-items: center;\n  justify-content: center;\n  margin: 0;\n  padding: 0;\n  border: 0;\n  border-radius: var(--ph-corner);\n  background: none;\n  color: var(--ph-text);\n  font-family: inherit;\n  cursor: pointer;\n  user-select: none;\n  -webkit-user-select: none;\n  -webkit-touch-callout: none;\n  -webkit-tap-highlight-color: transparent;\n  touch-action: manipulation;\n  outline: none;\n  transition: transform 160ms var(--ph-ease-out);\n}\n\n.pulse-heart[data-pressed] {\n  transform: scale(0.97);\n}\n\n.pulse-heart:focus-visible {\n  outline: 2px solid color-mix(in srgb, var(--ph-liked) 60%, transparent);\n  outline-offset: 3px;\n}\n\n.pulse-heart:disabled {\n  opacity: 0.55;\n  cursor: default;\n}\n\n.pulse-heart__pill {\n  display: inline-flex;\n  align-items: center;\n  gap: calc(var(--ph-size) * 0.25);\n  padding: calc(var(--ph-size) * 0.3);\n  padding-inline-end: calc(var(--ph-size) * 0.4);\n  border-radius: var(--ph-corner);\n  background: var(--ph-pill);\n  transform-origin: center;\n}\n\n.pulse-heart[data-no-count] .pulse-heart__pill {\n  padding-inline-end: calc(var(--ph-size) * 0.3);\n}\n\n.pulse-heart__heart {\n  display: block;\n  width: var(--ph-size);\n  height: var(--ph-size);\n  color: var(--ph-idle);\n  transform-origin: center;\n  transition: color 160ms ease;\n}\n\n.pulse-heart__heart svg {\n  display: block;\n  width: 100%;\n  height: 100%;\n  overflow: visible;\n  fill: none;\n  stroke: currentColor;\n  stroke-width: var(--ph-stroke);\n  stroke-linecap: round;\n  stroke-linejoin: round;\n}\n\n.pulse-heart[data-solid] .pulse-heart__heart svg,\n.pulse-heart[data-liked='true'] .pulse-heart__heart svg {\n  fill: currentColor;\n}\n\n.pulse-heart[data-liked='true'] .pulse-heart__heart {\n  color: var(--ph-liked);\n}\n\n.pulse-heart[data-running] .pulse-heart__heart,\n.pulse-heart[data-instant] .pulse-heart__heart {\n  transition: none;\n}\n\n@media (hover: hover) and (pointer: fine) {\n  .pulse-heart:not([data-liked='true']):not(:disabled):hover .pulse-heart__heart {\n    color: color-mix(in srgb, var(--ph-idle) 45%, var(--ph-liked));\n  }\n}\n\n.pulse-heart__count {\n  display: inline-flex;\n  font-size: calc(var(--ph-size) * 0.5);\n  font-weight: 600;\n  line-height: 1;\n  letter-spacing: -0.1px;\n  font-variant-numeric: tabular-nums;\n  color: var(--ph-text);\n}\n\n.pulse-heart__count > span {\n  display: block;\n  height: 1em;\n}\n\n.pulse-heart__slot {\n  position: relative;\n  overflow: hidden;\n}\n\n.pulse-heart__roll {\n  display: flex;\n  flex-direction: column;\n  transition: transform var(--ph-roll) var(--ph-ease-out);\n}\n\n.pulse-heart__roll > span {\n  display: block;\n  height: 1em;\n  line-height: 1;\n}\n\n.pulse-heart__sr {\n  position: absolute;\n  width: 1px;\n  height: 1px;\n  margin: -1px;\n  padding: 0;\n  border: 0;\n  overflow: hidden;\n  clip: rect(0 0 0 0);\n  white-space: nowrap;\n}\n\n@media (pointer: coarse) {\n  .pulse-heart {\n    min-width: 44px;\n    min-height: 44px;\n  }\n}\n\n@media (prefers-contrast: more) {\n  .pulse-heart__pill {\n    box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--ph-text) 25%, transparent);\n  }\n}\n\n@media (prefers-reduced-motion: reduce) {\n  .pulse-heart {\n    transition: none;\n  }\n\n  .pulse-heart__heart {\n    transition: color 200ms ease;\n  }\n\n  .pulse-heart__roll {\n    transition: none;\n  }\n}\n"
		},
		{
			"type": "registry:component",
			"path": "PulseHeart.tsx",
			"content": "'use client';\n\nimport React, { useEffect, useLayoutEffect, useRef, useState, type CSSProperties, type ReactNode } from 'react';\nimport { FavouriteIcon, StarIcon, ThumbsUpIcon } from '@hugeicons/core-free-icons';\n\nimport './PulseHeart.css';\n\nexport type PulseHeartIcon = 'heart' | 'star' | 'thumb';\n\nexport interface PulseHeartProps {\n  liked?: boolean;\n  defaultLiked?: boolean;\n  count?: number;\n  onChange?: (liked: boolean, count: number) => void;\n  showCount?: boolean;\n  icon?: PulseHeartIcon | ReactNode;\n  idleOutline?: boolean;\n  size?: number;\n  corner?: number;\n  likedColor?: string;\n  idleColor?: string;\n  pillColor?: string;\n  textColor?: string;\n  duration?: number;\n  dotSize?: number;\n  overshoot?: number;\n  beat?: number;\n  rollDuration?: number;\n  disabled?: boolean;\n  label?: string;\n  className?: string;\n}\n\ntype IconData = readonly (readonly [string, { readonly [key: string]: string | number }])[];\ntype Roll = { a: string; b: string; at: number; up: boolean };\ntype Cell = { ch: string } | { top: string; bottom: string };\n\nconst OUT = 0.4;\nconst ICONS: Record<PulseHeartIcon, IconData> = { heart: FavouriteIcon, star: StarIcon, thumb: ThumbsUpIcon };\n\nconst back = (k: number, c: number) => {\n  const u = k - 1;\n  return 1 + (c + 1) * u ** 3 + c * u ** 2;\n};\nconst swellOf = (t: number, c: number) =>\n  t <= 0 ? 0 : t < OUT ? 1 - (1 - t / OUT) ** 3 : 1 - back((t - OUT) / (1 - OUT), c);\nconst format = (n: number) => new Intl.NumberFormat().format(n);\nconst reducedMotion = () =>\n  typeof window !== 'undefined' && !!window.matchMedia?.('(prefers-reduced-motion: reduce)').matches;\n\nconst PulseHeart: React.FC<PulseHeartProps> = ({\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<Roll | null>(null);\n\n  const rootRef = useRef<HTMLButtonElement>(null);\n  const pillRef = useRef<HTMLSpanElement>(null);\n  const heartRef = useRef<HTMLSpanElement>(null);\n  const glyphRef = useRef<SVGGElement>(null);\n  const rollRef = useRef<HTMLSpanElement>(null);\n  const raf = useRef(0);\n  const rollTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);\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: number, to: number) => {\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: boolean, nextCount: number) => {\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: number) => {\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: React.PointerEvent<HTMLButtonElement>) => {\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: React.MouseEvent<HTMLButtonElement>) => {\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 as PulseHeartIcon] || ICONS.heart : null;\n  const text = format(shown.count);\n  const cells: Cell[] = 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={`pulse-heart${className ? ` ${className}` : ''}`}\n      style={\n        {\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        } as CSSProperties\n      }\n      onPointerDown={handlePointerDown}\n      onPointerUp={handlePointerUp}\n      onPointerLeave={handlePointerUp}\n      onPointerCancel={handlePointerCancel}\n      onKeyDown={handleKeyDown}\n      onClick={handleClick}\n    >\n      <span ref={pillRef} className=\"pulse-heart__pill\">\n        <span ref={heartRef} className=\"pulse-heart__heart\" aria-hidden=\"true\">\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 className=\"pulse-heart__count\" aria-hidden=\"true\">\n            {cells.map((cell, i) =>\n              'ch' in cell ? (\n                <span key={`c${i}`}>{cell.ch}</span>\n              ) : (\n                <span key={`r${i}`} className=\"pulse-heart__slot\">\n                  <span ref={rollRef} className=\"pulse-heart__roll\">\n                    <span>{cell.top}</span>\n                    <span>{cell.bottom}</span>\n                  </span>\n                </span>\n              )\n            )}\n          </span>\n        ) : null}\n        <span className=\"pulse-heart__sr\">{showCount ? `${label}, ${format(total)}` : label}</span>\n      </span>\n    </button>\n  );\n};\n\nexport default PulseHeart;\n"
		}
	],
	"registryDependencies": [],
	"dependencies": [
		"@hugeicons/core-free-icons@^4.3.3"
	]
}