跳至正文
组件

Ripple波纹脉冲

同心波纹与径向标记,支持扩散脉冲、覆盖范围与离屏暂停。

正在加载…

安装

pnpm dlx shadcn@latest add https://yiui.dev/r/ripple.json
会连带安装 本站条目 grid-layout、flow-diagram。已存在的文件会询问是否覆盖。

用法

pulse 模式让波纹持续向外扩散,reach 模式保留固定圆环并轻轻改变透明度。内容通过 RippleMarker 按角度和半径定位,radius={0} 表示中心。tone 给波纹上色,同时写入 --flow-tone,标记里可以直接用 bg-(--flow-tone) 取同一个颜色。页首的预览是 pulse 模式加四个地区标记:波纹持续扩散,标记按延迟远近落在不同半径上。

components/ripple-example.tsx
import { Ripple, RippleMarker } from "@/components/ui/ripple/ripple"

export function RippleExample() {
  return (
    <Ripple mode="reach" label="服务覆盖范围">
      <RippleMarker radius={0}>中心</RippleMarker>
      <RippleMarker angle={45} radius={0.8} node="华东" />
    </Ripple>
  )
}

示例

每个示例的标题栏右侧都有「调整」开关:打开后可以实时调整模式、环数、环间距、周期与色调,并开关暂停;未改动的项显示示例自己的取值。调整时外框与设计画布大小不变,环数和间距只改变圆环本身。

在线状态

tone="green" 让脉冲呈绿色,头像上的状态点取同一个 --flow-tone;在线状态同时通过文字表达。

正在加载…

只有中心

最小用法:一个中心标记加 tone,波纹与中心标记同色。

正在加载…

固定圆环

mode="reach" 保留固定圆环,便于比较距离。中心展示信号状态,圆环旁用距离刻度说明覆盖范围,背景使用轻斜纹。圆环直径 100 / 200 / 300,RippleMarker 的 radius 取 1/3、2/3、1,刻度正好落在对应圆环上。

正在加载…

API

Ripple

默认 rings={4}、ringGap={48}、baseSize={48}、duration={4}、mode="pulse"。不传 tone 时波纹沿用主题线条色。ringGap 表示相邻圆环的直径差,最外圈直径为 baseSize + (rings - 1) × ringGap;脉冲模式扩散到再外一圈后淡出。数量限制为 1–24,负间距按 0 处理,时长最小 0.1 秒。

size 默认 320,表示设计画布边长。容器更窄时圆环与标记位置等比缩小,内容字号保持不变。外框默认高 288px,可用 className 调整。底纹支持 dots / grid / lines / hatch / none。

RippleMarker

angle 默认 0,0 度在上方,正值顺时针。radius 默认 1,相对于最外圈半径:0 在中心,1 在最外圈,大于 1 在圆环外。node 和 children 均可承载内容,同时传入时使用 node。

可访问性

  • 默认作为装饰隐藏;传入 label 后以图片语义朗读概括说明。
  • 内容应是非交互元素,图示不产生键盘焦点。
  • 系统开启“减少动态效果”时显示错开的完整静帧;离开视口或 paused 时停止计时,恢复后继续。
  • 状态用文字配合颜色表达,数字有明确的单位说明。

注意事项

  • 调整面板通过 RippleTuningContext 覆盖子树里 Ripple 的 rings、ringGap、baseSize、mode、duration、paused、background 与 tone,并用 onReport 回报示例自己的取值。不包这一层时组件行为不变;它面向调试与文档,正常使用不需要。
  • 动画由共享 MotionValue 推导,每帧不触发 React 渲染。服务端输出确定性的同心环,不使用随机粒子,可用于静态导出。
  • RippleMarker 应放在 Ripple 内。它按设计画布定位,不会跟随脉冲伸缩;覆盖关系明确的场景建议使用 reach。
  • 大半径或长标签可能被容器边缘裁切,请预留标记尺寸。高度可以按使用场景调整,小格子中建议减少环数。
  • 底纹和变量依赖 flow-diagram registry 条目,安装时会一并提供;手动安装时需自行引入对应工具类和变量。

源码

components/ui/ripple/ripple.tsx
"use client"

import * as React from "react"
import {
  motion,
  useInView,
  useMotionValue,
  useTransform,
  type MotionValue,
} from "motion/react"
import { cn } from "@/lib/utils"
import {
  backgroundClasses,
  FADE_MASK,
  type FlowBackgroundName,
} from "@/components/ui/flow-diagram/background"
import { flowToneStyle, type FlowTone } from "@/components/ui/flow-diagram/tone"

// 订阅系统偏好的实时变更,服务端始终使用静帧。
function subscribeReducedMotion(callback: () => void) {
  const media = window.matchMedia("(prefers-reduced-motion: reduce)")
  media.addEventListener("change", callback)
  return () => media.removeEventListener("change", callback)
}
function reducedMotionSnapshot() {
  return window.matchMedia("(prefers-reduced-motion: reduce)").matches
}
function serverReducedMotionSnapshot() {
  return true
}
function useRippleReducedMotion() {
  return React.useSyncExternalStore(
    subscribeReducedMotion,
    reducedMotionSnapshot,
    serverReducedMotionSnapshot,
  )
}

type RippleMode = "pulse" | "reach"

/**
 * 调整用的覆盖项:子树里所有 `Ripple` 的对应属性被这里的值替换。
 * 用于调试面板一类的场景;不包 `RippleTuningContext.Provider` 时组件行为不变。
 */
type RippleTuning = {
  rings?: number
  ringGap?: number
  baseSize?: number
  mode?: RippleMode
  duration?: number
  paused?: boolean
  background?: FlowBackgroundName
  /** `false` 表示不上色,沿用主题线条色。 */
  tone?: FlowTone | false
  /** 回报示例自己的实际取值(未被覆盖前),面板据此显示当前值。 */
  onReport?: (values: RippleReport) => void
}

type RippleReport = {
  rings: number
  ringGap: number
  baseSize: number
  mode: RippleMode
  duration: number
  paused: boolean
  background: FlowBackgroundName
  tone: FlowTone | undefined
}

const RippleTuningContext = React.createContext<RippleTuning>({})
const RippleContext = React.createContext({ outerSize: 256, size: 320 })

function Ripple({
  rings: ringsProp = 4,
  ringGap: ringGapProp = 48,
  baseSize: baseSizeProp = 48,
  mode: modeProp = "pulse",
  duration: durationProp = 4,
  paused: pausedProp = false,
  size = 320,
  background: backgroundProp = "dots",
  tone: toneProp,
  label,
  className,
  style,
  children,
  ...props
}: React.ComponentProps<"div"> & {
  /** 同心环数量,限制为 1–24。 */
  rings?: number
  /** 相邻圆环直径的差值,单位为设计像素。 */
  ringGap?: number
  baseSize?: number
  mode?: RippleMode
  duration?: number
  paused?: boolean
  /** 设计画布边长,容器窄时整体缩小。 */
  size?: number
  background?: FlowBackgroundName
  /**
   * 波纹色调:内置名或任意 CSS 颜色;不传沿用主题的线条色。
   * 同时写入 `--flow-tone`,内部的 `RippleMarker` 可用 `bg-(--flow-tone)` 取同一个颜色。
   */
  tone?: FlowTone
  /** 图示说明;不传时作为装饰隐藏。内部不应放交互元素。 */
  label?: string
}) {
  const ref = React.useRef<HTMLDivElement>(null)
  const inView = useInView(ref, { amount: 0.2 })
  const reduced = useRippleReducedMotion()
  const time = useMotionValue(0)
  const tuning = React.useContext(RippleTuningContext)
  const { onReport } = tuning
  React.useEffect(() => {
    onReport?.({
      rings: ringsProp,
      ringGap: ringGapProp,
      baseSize: baseSizeProp,
      mode: modeProp,
      duration: durationProp,
      paused: pausedProp,
      background: backgroundProp,
      tone: toneProp,
    })
  }, [
    onReport,
    ringsProp,
    ringGapProp,
    baseSizeProp,
    modeProp,
    durationProp,
    pausedProp,
    backgroundProp,
    toneProp,
  ])
  const rings = tuning.rings ?? ringsProp
  const ringGap = tuning.ringGap ?? ringGapProp
  const baseSize = tuning.baseSize ?? baseSizeProp
  const mode = tuning.mode ?? modeProp
  const duration = tuning.duration ?? durationProp
  const paused = tuning.paused ?? pausedProp
  const background = tuning.background ?? backgroundProp
  const tone = tuning.tone === false ? undefined : (tuning.tone ?? toneProp)
  const count = Math.min(24, Math.max(1, Math.floor(rings)))
  const base = Math.max(1, baseSize)
  const gap = Math.max(0, ringGap)
  const canvasSize = Math.max(1, size)
  const outerSize = base + (count - 1) * gap
  React.useEffect(() => {
    if (reduced) time.set(0)
  }, [reduced, time])
  React.useEffect(() => {
    if (paused || reduced || !inView) return
    let last = performance.now()
    let frame = requestAnimationFrame(function tick(now) {
      time.set(
        (time.get() +
          Math.min(now - last, 100) / 1000 / Math.max(0.1, duration)) %
          1,
      )
      last = now
      frame = requestAnimationFrame(tick)
    })
    return () => cancelAnimationFrame(frame)
  }, [paused, reduced, inView, duration, time])
  return (
    <div
      {...props}
      ref={ref}
      data-slot="ripple"
      data-mode={mode}
      role={label ? "img" : undefined}
      aria-label={label}
      aria-hidden={label ? undefined : true}
      className={cn(
        "relative flex h-72 w-full items-center justify-center overflow-hidden",
        className,
      )}
      style={{ ...flowToneStyle(tone), ...style }}
    >
      <div
        data-slot="ripple-background"
        className={cn(
          "pointer-events-none absolute inset-0",
          backgroundClasses[background],
          FADE_MASK,
        )}
      />
      <div
        data-slot="ripple-canvas"
        className="relative aspect-square shrink-0"
        style={{ width: `min(100%, ${canvasSize}px)` }}
      >
        {Array.from({ length: count }, (_, index) => (
          <RippleWave
            key={index}
            index={index}
            count={count}
            base={base}
            gap={gap}
            size={canvasSize}
            mode={mode}
            toned={tone !== undefined}
            time={time}
          />
        ))}
        <RippleContext.Provider value={{ outerSize, size: canvasSize }}>
          {children}
        </RippleContext.Provider>
      </div>
    </div>
  )
}

function RippleWave({
  index,
  count,
  base,
  gap,
  size,
  mode,
  toned,
  time,
}: {
  index: number
  count: number
  base: number
  gap: number
  size: number
  mode: RippleMode
  toned: boolean
  time: MotionValue<number>
}) {
  const progress = useTransform(
    time,
    (value) => (value + (index + 0.5) / count) % 1,
  )
  const diameter = mode === "pulse" ? base + count * gap : base + index * gap
  const scale = useTransform(progress, (value) =>
    mode === "pulse" ? (base + value * count * gap) / diameter : 1,
  )
  const opacity = useTransform(progress, (value) =>
    mode === "pulse"
      ? Math.sin(value * Math.PI) * 0.75
      : 0.55 + Math.sin(value * Math.PI * 2) * 0.2,
  )
  return (
    <motion.div
      data-slot="ripple-wave"
      aria-hidden
      className={cn(
        "pointer-events-none absolute top-1/2 left-1/2 aspect-square -translate-x-1/2 -translate-y-1/2 rounded-full border",
        toned
          ? "border-(--flow-tone)/50 bg-(--flow-tone)/8"
          : "border-edge bg-primary/2",
      )}
      style={{ width: `${(diameter / size) * 100}%`, scale, opacity }}
    />
  )
}

function RippleMarker({
  angle = 0,
  radius = 1,
  node,
  children,
  className,
  style,
  ...props
}: React.ComponentProps<"div"> & {
  /** 0 在上方,正值顺时针。 */
  angle?: number
  /** 相对最外圈半径的比例,0–1 位于覆盖范围内。 */
  radius?: number
  node?: React.ReactNode
}) {
  const { outerSize, size } = React.useContext(RippleContext)
  const distance = ((Math.max(0, radius) * outerSize) / size) * 50
  const radians = (angle * Math.PI) / 180
  return (
    <div
      {...props}
      data-slot="ripple-marker"
      className={cn(
        "absolute z-10 -translate-x-1/2 -translate-y-1/2",
        className,
      )}
      style={{
        left: `${(50 + Math.sin(radians) * distance).toFixed(3)}%`,
        top: `${(50 - Math.cos(radians) * distance).toFixed(3)}%`,
        ...style,
      }}
    >
      {node ?? children}
    </div>
  )
}

export { Ripple, RippleMarker, RippleTuningContext }
export type { RippleMode, RippleReport, RippleTuning }