{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "ripple",
  "title": "波纹脉冲",
  "description": "同心波纹与径向标记，支持扩散脉冲、覆盖范围与离屏暂停。",
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "https://yiui.dev/r/flow-diagram.json",
    "https://yiui.dev/r/flow-tone.json"
  ],
  "files": [
    {
      "path": "src/registry/ui/ripple/ripple.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport {\n  motion,\n  useInView,\n  useMotionValue,\n  useTransform,\n  type MotionValue,\n} from \"motion/react\"\nimport { cn } from \"@/lib/utils\"\nimport {\n  backgroundClasses,\n  FADE_MASK,\n  type FlowBackgroundName,\n} from \"@/components/ui/flow-diagram/background\"\nimport { flowToneStyle, type FlowTone } from \"@/lib/flow-tone\"\n\n// 订阅系统偏好的实时变更，服务端始终使用静帧。\nfunction subscribeReducedMotion(callback: () => void) {\n  const media = window.matchMedia(\"(prefers-reduced-motion: reduce)\")\n  media.addEventListener(\"change\", callback)\n  return () => media.removeEventListener(\"change\", callback)\n}\nfunction reducedMotionSnapshot() {\n  return window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches\n}\nfunction serverReducedMotionSnapshot() {\n  return true\n}\nfunction useRippleReducedMotion() {\n  return React.useSyncExternalStore(\n    subscribeReducedMotion,\n    reducedMotionSnapshot,\n    serverReducedMotionSnapshot,\n  )\n}\n\ntype RippleMode = \"pulse\" | \"reach\"\n\n/**\n * 调整用的覆盖项：子树里所有 `Ripple` 的对应属性被这里的值替换。\n * 用于调试面板一类的场景；不包 `RippleTuningContext.Provider` 时组件行为不变。\n */\ntype RippleTuning = {\n  rings?: number\n  ringGap?: number\n  baseSize?: number\n  mode?: RippleMode\n  duration?: number\n  paused?: boolean\n  background?: FlowBackgroundName\n  /** `false` 表示不上色，沿用主题线条色。 */\n  tone?: FlowTone | false\n  /** 回报示例自己的实际取值（未被覆盖前），面板据此显示当前值。 */\n  onReport?: (values: RippleReport) => void\n}\n\ntype RippleReport = {\n  rings: number\n  ringGap: number\n  baseSize: number\n  mode: RippleMode\n  duration: number\n  paused: boolean\n  background: FlowBackgroundName\n  tone: FlowTone | undefined\n}\n\nconst RippleTuningContext = React.createContext<RippleTuning>({})\nconst RippleContext = React.createContext({ outerSize: 256, size: 320 })\n\nfunction Ripple({\n  rings: ringsProp = 4,\n  ringGap: ringGapProp = 48,\n  baseSize: baseSizeProp = 48,\n  mode: modeProp = \"pulse\",\n  duration: durationProp = 4,\n  paused: pausedProp = false,\n  size = 320,\n  background: backgroundProp = \"dots\",\n  tone: toneProp,\n  label,\n  className,\n  style,\n  children,\n  ...props\n}: React.ComponentProps<\"div\"> & {\n  /** 同心环数量，限制为 1–24。 */\n  rings?: number\n  /** 相邻圆环直径的差值，单位为设计像素。 */\n  ringGap?: number\n  baseSize?: number\n  mode?: RippleMode\n  duration?: number\n  paused?: boolean\n  /** 设计画布边长，容器窄时整体缩小。 */\n  size?: number\n  background?: FlowBackgroundName\n  /**\n   * 波纹色调：内置名或任意 CSS 颜色；不传沿用主题的线条色。\n   * 同时写入 `--flow-tone`，内部的 `RippleMarker` 可用 `bg-(--flow-tone)` 取同一个颜色。\n   */\n  tone?: FlowTone\n  /** 图示说明；不传时作为装饰隐藏。内部不应放交互元素。 */\n  label?: string\n}) {\n  const ref = React.useRef<HTMLDivElement>(null)\n  const inView = useInView(ref, { amount: 0.2 })\n  const reduced = useRippleReducedMotion()\n  const time = useMotionValue(0)\n  const tuning = React.useContext(RippleTuningContext)\n  const { onReport } = tuning\n  React.useEffect(() => {\n    onReport?.({\n      rings: ringsProp,\n      ringGap: ringGapProp,\n      baseSize: baseSizeProp,\n      mode: modeProp,\n      duration: durationProp,\n      paused: pausedProp,\n      background: backgroundProp,\n      tone: toneProp,\n    })\n  }, [\n    onReport,\n    ringsProp,\n    ringGapProp,\n    baseSizeProp,\n    modeProp,\n    durationProp,\n    pausedProp,\n    backgroundProp,\n    toneProp,\n  ])\n  const rings = tuning.rings ?? ringsProp\n  const ringGap = tuning.ringGap ?? ringGapProp\n  const baseSize = tuning.baseSize ?? baseSizeProp\n  const mode = tuning.mode ?? modeProp\n  const duration = tuning.duration ?? durationProp\n  const paused = tuning.paused ?? pausedProp\n  const background = tuning.background ?? backgroundProp\n  const tone = tuning.tone === false ? undefined : (tuning.tone ?? toneProp)\n  const count = Math.min(24, Math.max(1, Math.floor(rings)))\n  const base = Math.max(1, baseSize)\n  const gap = Math.max(0, ringGap)\n  const canvasSize = Math.max(1, size)\n  const outerSize = base + (count - 1) * gap\n  React.useEffect(() => {\n    if (reduced) time.set(0)\n  }, [reduced, time])\n  React.useEffect(() => {\n    if (paused || reduced || !inView) return\n    let last = performance.now()\n    let frame = requestAnimationFrame(function tick(now) {\n      time.set(\n        (time.get() +\n          Math.min(now - last, 100) / 1000 / Math.max(0.1, duration)) %\n          1,\n      )\n      last = now\n      frame = requestAnimationFrame(tick)\n    })\n    return () => cancelAnimationFrame(frame)\n  }, [paused, reduced, inView, duration, time])\n  return (\n    <div\n      {...props}\n      ref={ref}\n      data-slot=\"ripple\"\n      data-mode={mode}\n      role={label ? \"img\" : undefined}\n      aria-label={label}\n      aria-hidden={label ? undefined : true}\n      className={cn(\n        \"relative flex h-72 w-full items-center justify-center overflow-hidden\",\n        className,\n      )}\n      style={{ ...flowToneStyle(tone), ...style }}\n    >\n      <div\n        data-slot=\"ripple-background\"\n        className={cn(\n          \"pointer-events-none absolute inset-0\",\n          backgroundClasses[background],\n          FADE_MASK,\n        )}\n      />\n      <div\n        data-slot=\"ripple-canvas\"\n        className=\"relative aspect-square shrink-0\"\n        style={{ width: `min(100%, ${canvasSize}px)` }}\n      >\n        {Array.from({ length: count }, (_, index) => (\n          <RippleWave\n            key={index}\n            index={index}\n            count={count}\n            base={base}\n            gap={gap}\n            size={canvasSize}\n            mode={mode}\n            toned={tone !== undefined}\n            time={time}\n          />\n        ))}\n        <RippleContext.Provider value={{ outerSize, size: canvasSize }}>\n          {children}\n        </RippleContext.Provider>\n      </div>\n    </div>\n  )\n}\n\nfunction RippleWave({\n  index,\n  count,\n  base,\n  gap,\n  size,\n  mode,\n  toned,\n  time,\n}: {\n  index: number\n  count: number\n  base: number\n  gap: number\n  size: number\n  mode: RippleMode\n  toned: boolean\n  time: MotionValue<number>\n}) {\n  const progress = useTransform(\n    time,\n    (value) => (value + (index + 0.5) / count) % 1,\n  )\n  const diameter = mode === \"pulse\" ? base + count * gap : base + index * gap\n  const scale = useTransform(progress, (value) =>\n    mode === \"pulse\" ? (base + value * count * gap) / diameter : 1,\n  )\n  const opacity = useTransform(progress, (value) =>\n    mode === \"pulse\"\n      ? Math.sin(value * Math.PI) * 0.75\n      : 0.55 + Math.sin(value * Math.PI * 2) * 0.2,\n  )\n  return (\n    <motion.div\n      data-slot=\"ripple-wave\"\n      aria-hidden\n      className={cn(\n        \"pointer-events-none absolute top-1/2 left-1/2 aspect-square -translate-x-1/2 -translate-y-1/2 rounded-full border\",\n        toned\n          ? \"border-(--flow-tone)/50 bg-(--flow-tone)/8\"\n          : \"border-edge bg-primary/2\",\n      )}\n      style={{ width: `${(diameter / size) * 100}%`, scale, opacity }}\n    />\n  )\n}\n\nfunction RippleMarker({\n  angle = 0,\n  radius = 1,\n  node,\n  children,\n  className,\n  style,\n  ...props\n}: React.ComponentProps<\"div\"> & {\n  /** 0 在上方，正值顺时针。 */\n  angle?: number\n  /** 相对最外圈半径的比例，0–1 位于覆盖范围内。 */\n  radius?: number\n  node?: React.ReactNode\n}) {\n  const { outerSize, size } = React.useContext(RippleContext)\n  const distance = ((Math.max(0, radius) * outerSize) / size) * 50\n  const radians = (angle * Math.PI) / 180\n  return (\n    <div\n      {...props}\n      data-slot=\"ripple-marker\"\n      className={cn(\n        \"absolute z-10 -translate-x-1/2 -translate-y-1/2\",\n        className,\n      )}\n      style={{\n        left: `${(50 + Math.sin(radians) * distance).toFixed(3)}%`,\n        top: `${(50 - Math.cos(radians) * distance).toFixed(3)}%`,\n        ...style,\n      }}\n    >\n      {node ?? children}\n    </div>\n  )\n}\n\nexport { Ripple, RippleMarker, RippleTuningContext }\nexport type { RippleMode, RippleReport, RippleTuning }\n",
      "type": "registry:ui",
      "target": "@ui/ripple/ripple.tsx"
    }
  ],
  "cssVars": {
    "theme": {
      "color-edge": "var(--edge)"
    },
    "light": {
      "edge": "color-mix(in oklch, var(--foreground) 12%, transparent)"
    },
    "dark": {
      "edge": "color-mix(in oklch, var(--foreground) 18%, transparent)"
    }
  },
  "type": "registry:ui"
}
