{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "ripple",
  "type": "registry:ui",
  "title": "Ripple",
  "description": "A press wave from the pointer, in the surface's own text color.",
  "categories": [
    "motion"
  ],
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "https://www.stealth.pm/r/tokens.json",
    "https://www.stealth.pm/r/cn.json",
    "https://www.stealth.pm/r/motion.json"
  ],
  "files": [
    {
      "path": "components/ui/ripple.tsx",
      "content": "\"use client\";\nimport { motion, useReducedMotion } from \"motion/react\";\nimport { useCallback, useEffect, useRef, useState } from \"react\";\nimport { cn } from \"@/lib/cn\";\nimport { ease } from \"@/lib/motion\";\n\ntype Wave = {\n  id: number;\n  /** Centre of the wave in the host's own (unscaled) coordinates. */\n  x: number;\n  y: number;\n  /** Distance to the host's farthest corner, so the wave always covers it. */\n  r: number;\n  held: boolean;\n  /** Seconds to wait before fading, so a quick tap is still seen to spread. */\n  hold: number;\n  born: number;\n};\n\nexport type RippleIntensity = \"soft\" | \"default\" | \"strong\";\n\nexport type RippleProps = Omit<React.ComponentProps<\"span\">, \"children\"> & {\n  /** Every press ripples from the centre, for icon buttons, chips and radios where the pointer position means nothing. */\n  centered?: boolean;\n  /** Stops new ripples. The host being `:disabled` or `aria-disabled=\"true\"` does the same on its own. */\n  disabled?: boolean;\n  /** Peak opacity of the wave: 5%, 8% or 12% of the host's text color. */\n  intensity?: RippleIntensity;\n};\n\nconst peak: Record<RippleIntensity, number> = { soft: 0.05, default: 0.08, strong: 0.12 };\n\n// A touch that turns into a scroll shouldn't flash a ripple. Wait this long for\n// the browser to claim the gesture (it fires pointercancel) before drawing.\nconst TOUCH_INTENT_MS = 70;\n// Waves beyond this are dropped oldest-first when someone hammers the surface.\nconst MAX_WAVES = 4;\n\nconst isBlocked = (el: Element) => el.matches(':disabled, [aria-disabled=\"true\"], [data-disabled]');\n\n/**\n * Drop it inside any positioned surface (a button, a row, a card) and it listens\n * to that surface: presses ripple from the pointer, Enter and Space from the\n * centre. The wave takes the surface's text color, so it reads on a light row,\n * a dark primary button or a danger button without configuration.\n */\nexport function Ripple({ centered = false, disabled = false, intensity = \"default\", className, ref, ...rest }: RippleProps) {\n  const layer = useRef<HTMLSpanElement | null>(null);\n  const [waves, setWaves] = useState<Wave[]>([]);\n  const reduce = !!useReducedMotion();\n\n  const setRef = useCallback(\n    (node: HTMLSpanElement | null) => {\n      layer.current = node;\n      if (typeof ref === \"function\") return ref(node);\n      if (ref) ref.current = node;\n    },\n    [ref],\n  );\n\n  useEffect(() => {\n    const host = layer.current?.parentElement;\n    if (!host || disabled) return;\n\n    let seq = 0;\n    let touchTimer: number | undefined;\n    const live = new Set<number>();\n\n    const spawn = (clientX?: number, clientY?: number) => {\n      const rect = host.getBoundingClientRect();\n      const w = host.offsetWidth;\n      const h = host.offsetHeight;\n      if (!w || !h) return 0;\n      // The host may itself be scaled mid-press; map the pointer back into its own box.\n      const sx = rect.width / w || 1;\n      const sy = rect.height / h || 1;\n      const fromCentre = centered || clientX === undefined || clientY === undefined;\n      const x = fromCentre ? w / 2 : Math.min(Math.max((clientX - rect.left) / sx, 0), w);\n      const y = fromCentre ? h / 2 : Math.min(Math.max((clientY - rect.top) / sy, 0), h);\n      const r = Math.hypot(Math.max(x, w - x), Math.max(y, h - y));\n      const id = ++seq;\n      live.add(id);\n      setWaves((all) => [...all.slice(-(MAX_WAVES - 1)), { id, x, y, r, held: true, hold: 0, born: performance.now() }]);\n      return id;\n    };\n\n    const release = (id: number) => {\n      if (!live.delete(id)) return;\n      const now = performance.now();\n      setWaves((all) =>\n        all.map((wave) => (wave.id === id ? { ...wave, held: false, hold: Math.max(0, 0.09 - (now - wave.born) / 1000) } : wave)),\n      );\n    };\n    const releaseAll = () => live.forEach(release);\n\n    /* Pointer -------------------------------------------------------------- */\n\n    let pointerWave = 0;\n    const endPointer = () => {\n      window.clearTimeout(touchTimer);\n      window.removeEventListener(\"pointerup\", onUp);\n      window.removeEventListener(\"pointercancel\", onCancel);\n      host.removeEventListener(\"pointerleave\", onLeave);\n    };\n    const onUp = (e: PointerEvent) => {\n      // A tap shorter than the touch-intent wait still gets its ripple, then lets go.\n      if (!pointerWave && touchTimer !== undefined) pointerWave = spawn(e.clientX, e.clientY);\n      endPointer();\n      release(pointerWave);\n    };\n    // The browser took the touch for a scroll: never draw, or fade what was drawn.\n    const onCancel = () => {\n      endPointer();\n      release(pointerWave);\n    };\n    // Dragging off the surface lets go, the same way the click would be abandoned.\n    const onLeave = () => {\n      endPointer();\n      release(pointerWave);\n    };\n\n    const onDown = (e: PointerEvent) => {\n      if (e.button !== 0 || isBlocked(host)) return;\n      endPointer();\n      pointerWave = 0;\n      touchTimer = undefined;\n      window.addEventListener(\"pointerup\", onUp);\n      window.addEventListener(\"pointercancel\", onCancel);\n      host.addEventListener(\"pointerleave\", onLeave);\n      if (e.pointerType === \"touch\") {\n        const { clientX, clientY } = e;\n        touchTimer = window.setTimeout(() => {\n          touchTimer = undefined;\n          pointerWave = spawn(clientX, clientY);\n        }, TOUCH_INTENT_MS);\n      } else {\n        pointerWave = spawn(e.clientX, e.clientY);\n      }\n    };\n\n    /* Keyboard ------------------------------------------------------------- */\n\n    const keys = new Map<string, number>();\n    const onKeyDown = (e: KeyboardEvent) => {\n      // Only when the surface itself has focus, never from a field inside it.\n      if (e.target !== host || e.repeat || isBlocked(host)) return;\n      if (e.key !== \"Enter\" && e.key !== \" \") return;\n      if (keys.has(e.key)) return;\n      keys.set(e.key, spawn());\n    };\n    const onKeyUp = (e: KeyboardEvent) => {\n      const id = keys.get(e.key);\n      if (id === undefined) return;\n      keys.delete(e.key);\n      release(id);\n    };\n    const onBlur = () => {\n      keys.clear();\n      releaseAll();\n    };\n\n    host.addEventListener(\"pointerdown\", onDown);\n    host.addEventListener(\"keydown\", onKeyDown);\n    host.addEventListener(\"keyup\", onKeyUp);\n    host.addEventListener(\"blur\", onBlur);\n    return () => {\n      endPointer();\n      host.removeEventListener(\"pointerdown\", onDown);\n      host.removeEventListener(\"keydown\", onKeyDown);\n      host.removeEventListener(\"keyup\", onKeyUp);\n      host.removeEventListener(\"blur\", onBlur);\n    };\n  }, [centered, disabled]);\n\n  const remove = (id: number) => setWaves((all) => all.filter((wave) => wave.id !== id));\n  const opacity = peak[intensity];\n\n  return (\n    <span\n      ref={setRef}\n      aria-hidden\n      data-slot=\"ripple\"\n      className={cn(\n        \"pointer-events-none absolute inset-0 overflow-hidden rounded-[inherit] [contain:strict] forced-colors:hidden\",\n        className,\n      )}\n      {...rest}\n    >\n      {waves.map((wave) =>\n        reduce ? (\n          // Reduced motion: no spreading circle, a flat wash of the whole surface that fades.\n          <motion.span\n            key={wave.id}\n            className=\"absolute inset-0 bg-current\"\n            initial={{ opacity: opacity * 0.8 }}\n            animate={{ opacity: wave.held ? opacity * 0.8 : 0 }}\n            transition={{ duration: 0.2, delay: wave.hold, ease: \"linear\" }}\n            onAnimationComplete={() => !wave.held && remove(wave.id)}\n          />\n        ) : (\n          <motion.span\n            key={wave.id}\n            className=\"absolute rounded-full bg-current will-change-transform\"\n            style={{ left: wave.x - wave.r, top: wave.y - wave.r, width: wave.r * 2, height: wave.r * 2 }}\n            // Starts at full strength and small: the growth is the entrance, and a tap that's\n            // released on the same frame (touch) still shows before it fades.\n            initial={{ scale: 0.12, opacity }}\n            animate={{ scale: 1, opacity: wave.held ? opacity : 0 }}\n            transition={{\n              // Bigger surfaces take a little longer, so the wave's speed feels the same on a chip and a card.\n              scale: { duration: Math.min(0.62, 0.3 + wave.r * 0.0011), ease: ease.out },\n              opacity: { duration: 0.34, delay: wave.hold, ease: ease.outQuart },\n            }}\n            onAnimationComplete={() => !wave.held && remove(wave.id)}\n          />\n        ),\n      )}\n    </span>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/ripple"
}
