{
	"$schema": "https://ui.shadcn.com/schema/registry-item.json",
	"name": "SlingButton-TS-CSS",
	"title": "SlingButton",
	"description": "Send button you pull back like a slingshot and release to fire.",
	"type": "registry:component",
	"files": [
		{
			"type": "registry:file",
			"path": "SlingButton.css",
			"target": "@components/SlingButton.css",
			"content": ".sling-button {\n  --sl-size: 56px;\n  --sl-svg: 90px;\n  --sl-pad: #f5f5f5;\n  --sl-icon: #18181b;\n  --sl-accent: #f5f5f5;\n  --sl-well: #27272a;\n  --sl-band: #52525b;\n  --sl-stroke: 3px;\n  --sl-dot: 8px;\n  --sl-ease-out: cubic-bezier(0.23, 1, 0.32, 1);\n\n  position: relative;\n  display: inline-block;\n  width: var(--sl-size);\n  height: var(--sl-size);\n}\n\n.sling-button__fx {\n  position: absolute;\n  top: 50%;\n  left: 50%;\n  width: var(--sl-svg);\n  height: var(--sl-svg);\n  margin: calc(var(--sl-svg) / -2) 0 0 calc(var(--sl-svg) / -2);\n  overflow: visible;\n  pointer-events: none;\n  shape-rendering: geometricPrecision;\n}\n\n.sling-button__well {\n  fill: var(--sl-well);\n  transition: fill 200ms ease;\n}\n\n.sling-button[data-sent] .sling-button__well {\n  fill: var(--sl-accent);\n}\n\n.sling-button__band {\n  fill: none;\n  stroke: var(--sl-band);\n  stroke-width: var(--sl-stroke);\n  stroke-linecap: round;\n  transition: stroke-width 160ms var(--sl-ease-out);\n}\n\n.sling-button[data-armed] .sling-button__band {\n  stroke-width: calc(var(--sl-stroke) * 1.5);\n}\n\n.sling-button__band--hot {\n  stroke: var(--sl-accent);\n}\n\n.sling-button__arc {\n  fill: none;\n  stroke: var(--sl-accent);\n  stroke-width: var(--sl-stroke);\n  stroke-linecap: butt;\n  transition: stroke-width 160ms var(--sl-ease-out);\n}\n\n.sling-button[data-armed] .sling-button__arc {\n  stroke-width: calc(var(--sl-stroke) * 1.5);\n}\n\n.sling-button__move {\n  position: absolute;\n  inset: 0;\n}\n\n.sling-button__pad {\n  position: relative;\n  display: block;\n  width: 100%;\n  height: 100%;\n  margin: 0;\n  padding: 0;\n  border: 0;\n  border-radius: 50%;\n  background: none;\n  cursor: grab;\n  outline: none;\n  touch-action: none;\n  user-select: none;\n  -webkit-user-select: none;\n  -webkit-touch-callout: none;\n  -webkit-tap-highlight-color: transparent;\n}\n\n.sling-button__pad::after {\n  content: '';\n  position: absolute;\n  inset: -6px;\n  border-radius: 50%;\n}\n\n.sling-button__pad[data-held] {\n  cursor: grabbing;\n}\n\n.sling-button__pad[aria-disabled='true'] {\n  cursor: not-allowed;\n  opacity: 0.5;\n}\n\n.sling-button__face {\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  width: 100%;\n  height: 100%;\n  border-radius: 50%;\n  background: var(--sl-pad);\n  color: var(--sl-icon);\n  transition: transform 160ms var(--sl-ease-out);\n}\n\n.sling-button__pad[data-held] .sling-button__face {\n  transform: scale(0.97);\n}\n\n.sling-button__pad[data-armed] .sling-button__face {\n  transform: scale(1.04);\n}\n\n@media (hover: hover) and (pointer: fine) {\n  .sling-button__pad:not([data-held]):not([aria-disabled='true']):hover .sling-button__face {\n    transform: scale(1.02);\n  }\n}\n\n.sling-button__icon {\n  display: inline-flex;\n  will-change: transform;\n}\n\n.sling-button__dot {\n  position: absolute;\n  top: 50%;\n  left: 50%;\n  width: var(--sl-dot);\n  height: var(--sl-dot);\n  margin: calc(var(--sl-dot) / -2) 0 0 calc(var(--sl-dot) / -2);\n  border-radius: 50%;\n  background: var(--sl-accent);\n  opacity: 0;\n  pointer-events: none;\n}\n\n.sling-button__sr {\n  position: absolute;\n  width: 1px;\n  height: 1px;\n  overflow: hidden;\n  clip: rect(0 0 0 0);\n  white-space: nowrap;\n}\n\n@media (prefers-reduced-motion: reduce) {\n  .sling-button__face {\n    transform: none !important;\n    transition: none;\n  }\n}\n"
		},
		{
			"type": "registry:component",
			"path": "SlingButton.tsx",
			"content": "'use client';\n\nimport React, { useEffect, useId, useRef, useState, type CSSProperties, type ReactNode } from 'react';\nimport {\n  animate,\n  motion,\n  useMotionTemplate,\n  useMotionValue,\n  useMotionValueEvent,\n  useReducedMotion,\n  type AnimationPlaybackControls\n} from 'motion/react';\nimport { HugeiconsIcon } from '@hugeicons/react';\nimport { ArrowUp02Icon } from '@hugeicons/core-free-icons';\n\nimport './SlingButton.css';\n\nexport type SlingAxis = 'any' | 'horizontal' | 'vertical';\n\nexport interface SlingButtonProps {\n  children?: ReactNode;\n  onSend?: () => void;\n  padColor?: string;\n  iconColor?: string;\n  accentColor?: string;\n  wellColor?: string;\n  bandColor?: string;\n  size?: number;\n  strokeWidth?: number;\n  armAt?: number;\n  maxPull?: number;\n  launchSpeed?: number;\n  recoil?: number;\n  flight?: number;\n  particles?: number;\n  spread?: number;\n  axis?: SlingAxis;\n  tapSends?: boolean;\n  disabled?: boolean;\n  ariaLabel?: string;\n  className?: string;\n}\n\ninterface Sample {\n  x: number;\n  y: number;\n  t: number;\n}\n\ninterface Grip {\n  id: number;\n  startX: number;\n  startY: number;\n  scale: number;\n  moved: boolean;\n  hist: Sample[];\n  rawOrigin: { x: number; y: number };\n  slop: number;\n}\n\nconst GAP = 4;\nconst SLOP = { fine: 4, coarse: 8 };\nconst FINGER_MAX = 3000;\nconst HAND_MAX = 6000;\nconst CANCEL = 0.5;\nconst POWER_CAP = 1.5;\nconst DOT_MS = 300;\nconst EASE_OUT = 'cubic-bezier(0.23, 1, 0.32, 1)';\n\nconst clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v));\nconst rubberband = (o: number, dim: number, c = 0.55) => (o * dim * c) / (dim + c * Math.abs(o));\n\nconst SlingButton: React.FC<SlingButtonProps> = ({\n  children,\n  onSend,\n  padColor = '#f5f5f5',\n  iconColor = '#18181b',\n  accentColor = '#f5f5f5',\n  wellColor = '#27272a',\n  bandColor = '#52525b',\n  size = 56,\n  strokeWidth = 3,\n  armAt = 48,\n  maxPull = 160,\n  launchSpeed = 2600,\n  recoil = 0.2,\n  flight = 120,\n  particles = 14,\n  spread = 60,\n  axis = 'any',\n  tapSends = true,\n  disabled = false,\n  ariaLabel = 'Send',\n  className = ''\n}) => {\n  const reduce = useReducedMotion();\n  const R = maxPull;\n  const ARM = Math.min(armAt, 0.8 * R);\n  const wellR = size / 2 + GAP + strokeWidth;\n  const padR = size / 2 - strokeWidth / 2;\n  const arcR = wellR;\n  const H = wellR + strokeWidth + 2;\n  const DOT = Math.max(6, Math.round(size / 7));\n  const count = Math.max(0, Math.round(particles));\n  const [held, setHeld] = useState(false);\n  const [armed, setArmed] = useState(false);\n  const [sent, setSent] = useState(false);\n  const rootRef = useRef<HTMLSpanElement>(null);\n  const padRef = useRef<HTMLButtonElement>(null);\n  const fxRef = useRef<SVGGElement>(null);\n  const bandRef = useRef<SVGPathElement>(null);\n  const hotRef = useRef<SVGPathElement>(null);\n  const arcRef = useRef<SVGCircleElement>(null);\n  const dotRefs = useRef<(HTMLSpanElement | null)[]>([]);\n  const power = useRef(0);\n  const iconRef = useRef<HTMLSpanElement>(null);\n  const grip = useRef<Grip | null>(null);\n  const dir = useRef({ ux: 0, uy: -1 });\n  const animX = useRef<AnimationPlaybackControls | null>(null);\n  const animY = useRef<AnimationPlaybackControls | null>(null);\n  const armedRef = useRef(false);\n  const dotPending = useRef(false);\n  const dotTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);\n  const paintQueued = useRef(false);\n  const skipClick = useRef(false);\n  const hintId = useId();\n\n  const px = useMotionValue(0);\n  const py = useMotionValue(0);\n  const padT = useMotionTemplate`translate(${px}px, ${py}px)`;\n\n  const launchDot = () => {\n    dotPending.current = false;\n    clearTimeout(dotTimer.current);\n    const { ux, uy } = dir.current;\n    relaxIcon();\n    const base = Math.atan2(-uy, -ux);\n    const cone = (spread * Math.PI) / 180;\n    const push = 0.85 + 0.35 * power.current;\n    dotRefs.current.forEach((dot, i) => {\n      if (!dot) return;\n      const lead = i === 0;\n      const angle = base + (lead ? 0 : (Math.random() + Math.random() - 1) * (cone / 2));\n      const cx = Math.cos(angle);\n      const cy = Math.sin(angle);\n      const reach = (lead ? flight : flight * (0.3 + Math.random())) * push;\n      const drift = lead ? 0 : (Math.random() - 0.5) * flight * 0.4;\n      const scale = lead ? 1 : 0.3 + Math.random() * 0.6;\n      const shrink = lead ? 0.6 : scale * (0.2 + Math.random() * 0.4);\n      const duration = lead ? DOT_MS : DOT_MS * (0.7 + Math.random());\n      const delay = lead ? 0 : Math.random() * 70;\n      const to = wellR + reach;\n      dot.animate(\n        [\n          { transform: `translate(${cx * wellR}px, ${cy * wellR}px) scale(${scale})` },\n          { transform: `translate(${cx * to - cy * drift}px, ${cy * to + cx * drift}px) scale(${shrink})` }\n        ],\n        { duration, delay, easing: EASE_OUT, fill: 'none' }\n      );\n      dot.animate(\n        [\n          { opacity: 1, offset: 0 },\n          { opacity: 1, offset: 0.55 },\n          { opacity: 0, offset: 1 }\n        ],\n        { duration, delay, easing: 'linear', fill: 'none' }\n      );\n    });\n  };\n\n  const aimIcon = (ux: number, uy: number, dist: number) => {\n    const icon = iconRef.current;\n    if (!icon) return;\n    const angle = (Math.atan2(-uy, -ux) * 180) / Math.PI + 90;\n    icon.style.transition = 'none';\n    icon.style.transform = `rotate(${angle * clamp(dist / 12, 0, 1)}deg)`;\n  };\n  const relaxIcon = () => {\n    const icon = iconRef.current;\n    if (!icon) return;\n    icon.style.transition = reduce ? 'none' : 'transform 360ms cubic-bezier(0.23, 1, 0.32, 1)';\n    icon.style.transform = 'rotate(0deg)';\n  };\n\n  const paint = () => {\n    paintQueued.current = false;\n    const band = bandRef.current;\n    const hot = hotRef.current;\n    const arc = arcRef.current;\n    const fx = fxRef.current;\n    if (!band || !hot || !arc || !fx) return;\n    const x = px.get();\n    const y = py.get();\n    const { ux, uy } = dir.current;\n    const proj = x * ux + y * uy;\n    const p = clamp(proj / ARM, 0, 1);\n    const dist = Math.hypot(x, y);\n    let d = '';\n    if (dist > 0.5) {\n      const a = Math.atan2(y, x);\n      const b = Math.acos(clamp((wellR - padR) / dist, -1, 1));\n      d = [a + b, a - b]\n        .map(t => {\n          const cx = Math.cos(t);\n          const cy = Math.sin(t);\n          return `M${(wellR * cx).toFixed(2)},${(wellR * cy).toFixed(2)}L${(x + padR * cx).toFixed(2)},${(y + padR * cy).toFixed(2)}`;\n        })\n        .join('');\n    }\n    band.setAttribute('d', d);\n    hot.setAttribute('d', d);\n    hot.style.opacity = String(p);\n    fx.style.opacity = String(clamp(proj / 6, 0, 1));\n    arc.setAttribute('stroke-dasharray', `${p} ${1 - p}`);\n    arc.setAttribute('stroke-dashoffset', String(p / 2));\n    arc.style.opacity = p > 0.01 ? '1' : '0';\n    if (grip.current) aimIcon(ux, uy, dist);\n    arc.setAttribute('transform', `rotate(${(Math.atan2(-uy, -ux) * 180) / Math.PI})`);\n    if (dotPending.current && proj <= size / 4) launchDot();\n  };\n  const schedulePaint = () => {\n    if (paintQueued.current) return;\n    paintQueued.current = true;\n    requestAnimationFrame(paint);\n  };\n  useMotionValueEvent(px, 'change', schedulePaint);\n  useMotionValueEvent(py, 'change', schedulePaint);\n  useEffect(() => {\n    animX.current?.stop();\n    animY.current?.stop();\n    px.jump(0);\n    py.jump(0);\n    paint();\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [size, strokeWidth, armAt, maxPull, axis]);\n\n  const settle = (v0: { x: number; y: number }) => {\n    if (reduce) {\n      const fx = fxRef.current;\n      if (fx) {\n        fx.style.transition = 'opacity 200ms ease';\n        fx.style.opacity = '0';\n      }\n      setTimeout(() => {\n        px.jump(0);\n        py.jump(0);\n        if (fx) fx.style.transition = '';\n      }, 200);\n      return;\n    }\n    animX.current = animate(px, 0, { type: 'spring', duration: 0.4, bounce: recoil, velocity: v0.x });\n    animY.current = animate(py, 0, { type: 'spring', duration: 0.4, bounce: recoil, velocity: v0.y });\n  };\n\n  const onPointerDown = (e: React.PointerEvent<HTMLButtonElement>) => {\n    if (disabled || grip.current || e.button !== 0) return;\n    const el = rootRef.current;\n    if (!el) return;\n    const rect = el.getBoundingClientRect();\n    const scale = rect.width / (el.offsetWidth || rect.width) || 1;\n    animX.current?.stop();\n    animY.current?.stop();\n    const x = px.get();\n    const y = py.get();\n    const dNow = Math.hypot(x, y);\n    const dClamped = Math.min(dNow, 0.95 * R);\n    const rawNow = dNow > 0.5 ? (R * dClamped) / (R - dClamped) : 0;\n    grip.current = {\n      id: e.pointerId,\n      startX: e.clientX,\n      startY: e.clientY,\n      scale,\n      moved: false,\n      hist: [],\n      rawOrigin: dNow > 0.5 ? { x: (rawNow * x) / dNow, y: (rawNow * y) / dNow } : { x: 0, y: 0 },\n      slop: e.pointerType === 'touch' ? SLOP.coarse : SLOP.fine\n    };\n    try {\n      e.currentTarget.setPointerCapture(e.pointerId);\n    } catch {}\n    setHeld(true);\n  };\n  const onPointerMove = (e: React.PointerEvent<HTMLButtonElement>) => {\n    const g = grip.current;\n    if (!g || g.id !== e.pointerId) return;\n    const dx = (e.clientX - g.startX) / g.scale;\n    const dy = (e.clientY - g.startY) / g.scale;\n    let rx = g.rawOrigin.x + dx;\n    let ry = g.rawOrigin.y + dy;\n    if (axis === 'horizontal') ry = rubberband(ry, size / 4);\n    else if (axis === 'vertical') rx = rubberband(rx, size / 4);\n    if (!g.moved && Math.hypot(dx, dy) > g.slop) g.moved = true;\n    const raw = Math.hypot(rx, ry);\n    if (raw < 0.01) return;\n    const d = (R * raw) / (R + raw);\n    const ux = rx / raw;\n    const uy = ry / raw;\n    dir.current = { ux, uy };\n    px.set(d * ux);\n    py.set(d * uy);\n    const t = performance.now();\n    g.hist.push({ x: d * ux, y: d * uy, t });\n    while (g.hist.length > 4 || t - g.hist[0].t > 80) g.hist.shift();\n    const isArmed = d >= ARM;\n    if (isArmed !== armedRef.current) {\n      armedRef.current = isArmed;\n      setArmed(isArmed);\n    }\n  };\n  const release = (pointerId: number, cancelled: boolean) => {\n    const g = grip.current;\n    if (!g || g.id !== pointerId) return;\n    grip.current = null;\n    skipClick.current = true;\n    try {\n      padRef.current?.releasePointerCapture(pointerId);\n    } catch {}\n    const d = Math.hypot(px.get(), py.get());\n    const p = d / ARM;\n    const { ux, uy } = dir.current;\n    let vx = 0;\n    let vy = 0;\n    if (!cancelled && g.hist.length > 1) {\n      const a = g.hist[0];\n      const b = g.hist[g.hist.length - 1];\n      const dt = b.t - a.t;\n      if (dt > 0 && performance.now() - b.t < 50) {\n        vx = ((b.x - a.x) / dt) * 1000;\n        vy = ((b.y - a.y) / dt) * 1000;\n      }\n    }\n    const fm = Math.hypot(vx, vy);\n    if (fm > FINGER_MAX) {\n      vx *= FINGER_MAX / fm;\n      vy *= FINGER_MAX / fm;\n    }\n    if (!g.moved) {\n      relaxIcon();\n      if (tapSends && !cancelled) onSend?.();\n    } else {\n      const fire = armedRef.current && !cancelled;\n      const launch = fire ? launchSpeed * Math.min(p, POWER_CAP) : CANCEL * launchSpeed * Math.min(p, 1);\n      let v0x = vx - ux * launch;\n      let v0y = vy - uy * launch;\n      const m = Math.hypot(v0x, v0y);\n      if (m > HAND_MAX) {\n        v0x *= HAND_MAX / m;\n        v0y *= HAND_MAX / m;\n      }\n      if (!fire) relaxIcon();\n      if (fire) {\n        onSend?.();\n        if (reduce) {\n          setSent(true);\n          setTimeout(() => setSent(false), 200);\n        } else {\n          power.current = clamp((Math.min(p, POWER_CAP) - 1) / (POWER_CAP - 1), 0, 1);\n          dotPending.current = true;\n          dotTimer.current = setTimeout(launchDot, 150);\n        }\n      }\n      settle({ x: v0x, y: v0y });\n    }\n    armedRef.current = false;\n    setHeld(false);\n    setArmed(false);\n  };\n  useEffect(\n    () => () => {\n      animX.current?.stop();\n      animY.current?.stop();\n      clearTimeout(dotTimer.current);\n    },\n    []\n  );\n\n  return (\n    <span\n      ref={rootRef}\n      className={`sling-button${className ? ` ${className}` : ''}`}\n      data-armed={armed ? '' : undefined}\n      data-sent={sent ? '' : undefined}\n      style={\n        {\n          '--sl-size': `${size}px`,\n          '--sl-svg': `${2 * H}px`,\n          '--sl-pad': padColor,\n          '--sl-icon': iconColor,\n          '--sl-accent': accentColor,\n          '--sl-well': wellColor,\n          '--sl-band': bandColor,\n          '--sl-stroke': `${strokeWidth}px`,\n          '--sl-dot': `${DOT}px`\n        } as CSSProperties\n      }\n    >\n      <svg className=\"sling-button__fx\" viewBox={`${-H} ${-H} ${2 * H} ${2 * H}`} aria-hidden=\"true\">\n        <g ref={fxRef} className=\"sling-button__tension\" style={{ opacity: 0 }}>\n          <path ref={bandRef} className=\"sling-button__band\" />\n          <path ref={hotRef} className=\"sling-button__band sling-button__band--hot\" />\n        </g>\n        <circle className=\"sling-button__well\" r={wellR} />\n        <circle\n          ref={arcRef}\n          className=\"sling-button__arc\"\n          r={arcR}\n          pathLength=\"1\"\n          strokeDasharray=\"0 1\"\n          style={{ opacity: 0 }}\n        />\n      </svg>\n      {Array.from({ length: count }, (_, i) => (\n        <span\n          key={i}\n          ref={el => {\n            dotRefs.current[i] = el;\n          }}\n          className=\"sling-button__dot\"\n          aria-hidden=\"true\"\n        />\n      ))}\n      <motion.span className=\"sling-button__move\" style={{ transform: padT }}>\n        <button\n          ref={padRef}\n          type=\"button\"\n          className=\"sling-button__pad\"\n          aria-label={ariaLabel}\n          aria-describedby={hintId}\n          aria-disabled={disabled || undefined}\n          data-held={held ? '' : undefined}\n          data-armed={armed ? '' : undefined}\n          onPointerDown={onPointerDown}\n          onPointerMove={onPointerMove}\n          onPointerUp={e => release(e.pointerId, false)}\n          onPointerCancel={e => release(e.pointerId, true)}\n          onLostPointerCapture={e => release(e.pointerId, true)}\n          onKeyDown={e => {\n            if (e.key === 'Escape' && grip.current) release(grip.current.id, true);\n          }}\n          onClick={() => {\n            if (skipClick.current) {\n              skipClick.current = false;\n              return;\n            }\n            if (!disabled) onSend?.();\n          }}\n        >\n          <span className=\"sling-button__face\">\n            <span ref={iconRef} className=\"sling-button__icon\">\n              {children ?? <HugeiconsIcon icon={ArrowUp02Icon} size={Math.round(size * 0.4)} strokeWidth={2.2} />}\n            </span>\n          </span>\n        </button>\n      </motion.span>\n      <span id={hintId} className=\"sling-button__sr\">\n        Press Enter to send, or drag away and release.\n      </span>\n    </span>\n  );\n};\n\nexport default SlingButton;\n"
		}
	],
	"registryDependencies": [],
	"dependencies": [
		"motion@^12.23.12",
		"@hugeicons/react@^1.1.10",
		"@hugeicons/core-free-icons@^4.3.3"
	]
}