{
	"$schema": "https://ui.shadcn.com/schema/registry-item.json",
	"name": "SlingButton-TS-TW",
	"title": "SlingButton",
	"description": "Send button you pull back like a slingshot and release to fire.",
	"type": "registry:component",
	"files": [
		{
			"type": "registry:component",
			"path": "SlingButton/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\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={`group/root relative inline-block [width:var(--sl-size)] [height:var(--sl-size)]${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\n        className=\"pointer-events-none absolute top-1/2 left-1/2 overflow-visible [width:var(--sl-svg)] [height:var(--sl-svg)] [margin:calc(var(--sl-svg)/-2)_0_0_calc(var(--sl-svg)/-2)] [shape-rendering:geometricPrecision]\"\n        viewBox={`${-H} ${-H} ${2 * H} ${2 * H}`}\n        aria-hidden=\"true\"\n      >\n        <g ref={fxRef} className=\"sling-button__tension\" style={{ opacity: 0 }}>\n          <path\n            ref={bandRef}\n            className=\"fill-none [stroke:var(--sl-band)] [stroke-linecap:round] [stroke-width:var(--sl-stroke)] [transition:stroke-width_160ms_cubic-bezier(0.23,1,0.32,1)] group-data-[armed]/root:[stroke-width:calc(var(--sl-stroke)*1.5)]\"\n          />\n          <path\n            ref={hotRef}\n            className=\"fill-none [stroke:var(--sl-accent)] [stroke-linecap:round] [stroke-width:var(--sl-stroke)] [transition:stroke-width_160ms_cubic-bezier(0.23,1,0.32,1)] group-data-[armed]/root:[stroke-width:calc(var(--sl-stroke)*1.5)]\"\n          />\n        </g>\n        <circle\n          className=\"[fill:var(--sl-well)] [transition:fill_200ms_ease] group-data-[sent]/root:[fill:var(--sl-accent)]\"\n          r={wellR}\n        />\n        <circle\n          ref={arcRef}\n          className=\"fill-none [stroke:var(--sl-accent)] [stroke-width:var(--sl-stroke)] [stroke-linecap:butt] [transition:stroke-width_160ms_cubic-bezier(0.23,1,0.32,1)] group-data-[armed]/root:[stroke-width:calc(var(--sl-stroke)*1.5)]\"\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=\"pointer-events-none absolute top-1/2 left-1/2 rounded-full opacity-0 [width:var(--sl-dot)] [height:var(--sl-dot)] [margin:calc(var(--sl-dot)/-2)_0_0_calc(var(--sl-dot)/-2)] [background:var(--sl-accent)]\"\n          aria-hidden=\"true\"\n        />\n      ))}\n      <motion.span className=\"absolute inset-0\" style={{ transform: padT }}>\n        <button\n          ref={padRef}\n          type=\"button\"\n          className=\"group/pad relative m-0 block h-full w-full cursor-grab touch-none rounded-full border-0 bg-transparent p-0 outline-none select-none [-webkit-tap-highlight-color:transparent] [-webkit-touch-callout:none] after:absolute after:-inset-1.5 after:rounded-full after:content-[''] data-[held]:cursor-grabbing aria-disabled:cursor-not-allowed aria-disabled:opacity-50\"\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=\"flex h-full w-full items-center justify-center rounded-full [background:var(--sl-pad)] [color:var(--sl-icon)] [transition:transform_160ms_cubic-bezier(0.23,1,0.32,1)] group-data-[held]/pad:scale-[0.97] group-data-[armed]/pad:scale-[1.04] [@media(hover:hover)_and_(pointer:fine)]:group-hover/pad:group-not-data-[held]/pad:group-not-aria-disabled/pad:scale-[1.02] motion-reduce:transition-none motion-reduce:transform-none!\">\n            <span ref={iconRef} className=\"inline-flex will-change-transform\">\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=\"sr-only\">\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"
	]
}