import { cn } from "@/lib/utils"
import { Ripple, RippleMarker } from "@/components/ui/ripple/ripple"
import { Globe } from "lucide-react"
// 波纹从中心持续扩散;延迟越高的地区离中心越远,状态点同时配数字,不只靠颜色
const REGIONS = [
{ label: "华东", latency: "12 ms", angle: 50, radius: 0.55, ok: true },
{ label: "华南", latency: "24 ms", angle: 140, radius: 0.75, ok: true },
{ label: "新加坡", latency: "68 ms", angle: 225, radius: 0.92, ok: false },
{ label: "法兰克福", latency: "186 ms", angle: 318, radius: 1, ok: false },
]
export default function RippleDemo() {
return (
<Ripple
label="服务覆盖华东、华南、新加坡和法兰克福,延迟依次升高"
tone="blue"
rings={3}
baseSize={72}
ringGap={82}
size={340}
className="h-80"
>
<RippleMarker
radius={0}
className="flex size-14 items-center justify-center rounded-full bg-(--flow-tone) text-background shadow-lg ring-6 ring-(--flow-tone)/15"
>
<Globe className="size-7" />
</RippleMarker>
{REGIONS.map(({ label, latency, angle, radius, ok }) => (
<RippleMarker
key={label}
angle={angle}
radius={radius}
className="flex items-center gap-2 rounded-md border border-edge bg-background py-1.5 pr-2.5 pl-2 text-xs whitespace-nowrap shadow-sm"
>
<span
className={cn(
"size-2 rounded-full",
ok ? "bg-(--flow-green)" : "bg-(--flow-amber)",
)}
/>
<span className="font-medium">{label}</span>
<span className="font-mono text-[10px] text-muted-foreground">
{latency}
</span>
</RippleMarker>
))}
</Ripple>
)
}
安装
pnpm dlx shadcn@latest add https://yiui.dev/r/ripple.json用法
pulse 模式让波纹持续向外扩散,reach 模式保留固定圆环并轻轻改变透明度。内容通过 RippleMarker 按角度和半径定位,radius={0} 表示中心。tone 给波纹上色,同时写入 --flow-tone,标记里可以直接用 bg-(--flow-tone) 取同一个颜色。页首的预览是 pulse 模式加四个地区标记:波纹持续扩散,标记按延迟远近落在不同半径上。
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;在线状态同时通过文字表达。
import { Ripple, RippleMarker } from "@/components/ui/ripple/ripple"
export default function RippleOnlineDemo() {
return (
<Ripple
label="Travins 在线,可随时协作"
tone="green"
rings={4}
baseSize={64}
ringGap={48}
duration={5}
background="none"
>
<RippleMarker radius={0}>
<div className="relative flex size-20 items-center justify-center rounded-full border border-edge bg-muted font-mono text-2xl font-medium">
TY
<span className="absolute right-1 bottom-1 size-4 rounded-full border-4 border-background bg-(--flow-tone)" />
</div>
</RippleMarker>
<RippleMarker
angle={180}
radius={0.73}
className="rounded-md border border-edge bg-background px-3 py-1 text-xs whitespace-nowrap"
>
在线 · 可随时协作
</RippleMarker>
</Ripple>
)
}
只有中心
最小用法:一个中心标记加 tone,波纹与中心标记同色。
import { Ripple, RippleMarker } from "@/components/ui/ripple/ripple"
import { Radio } from "lucide-react"
export default function RipplePulseDemo() {
return (
<Ripple label="信号向四周扩散" tone="blue" duration={5} className="h-80">
<RippleMarker
radius={0}
className="flex size-14 items-center justify-center rounded-full bg-(--flow-tone) text-background shadow-lg"
>
<Radio className="size-6" />
</RippleMarker>
</Ripple>
)
}
固定圆环
mode="reach" 保留固定圆环,便于比较距离。中心展示信号状态,圆环旁用距离刻度说明覆盖范围,背景使用轻斜纹。圆环直径 100 / 200 / 300,RippleMarker 的 radius 取 1/3、2/3、1,刻度正好落在对应圆环上。
import { Ripple, RippleMarker } from "@/components/ui/ripple/ripple"
import { Radio } from "lucide-react"
// 圆环直径 100 / 200 / 300,半径之比 1 : 2 : 3;标记的 radius 是相对最外圈的比例,刻度正好落在对应的圆环上
const SCALE = [
{ km: 1, radius: 1 / 3 },
{ km: 2, radius: 2 / 3 },
{ km: 3, radius: 1 },
]
export default function RippleSignalDemo() {
return (
<Ripple
label="信号正常,覆盖半径 3 千米"
mode="reach"
rings={3}
baseSize={100}
ringGap={100}
size={360}
background="hatch"
className="h-[22.5rem]"
>
<RippleMarker
radius={0}
className="flex size-16 flex-col items-center justify-center gap-1 rounded-full border border-edge bg-background text-primary"
>
<Radio className="size-6" />
<span className="text-[10px]">信号正常</span>
</RippleMarker>
{SCALE.map(({ km, radius }) => (
<RippleMarker
key={km}
angle={90}
radius={radius}
className="bg-background px-1 font-mono text-[10px] whitespace-nowrap text-muted-foreground"
>
{km} km
</RippleMarker>
))}
</Ripple>
)
}
API
Ripple
同心环数量,限制为 1–24。
类型number | undefined相邻圆环直径的差值,单位为设计像素。
类型number | undefinednumber | undefinedRippleMode | undefinednumber | undefinedboolean | undefined设计画布边长,容器窄时整体缩小。
类型number | undefinedFlowBackgroundName | undefined波纹色调:内置名或任意 CSS 颜色;不传沿用主题的线条色。
同时写入 --flow-tone,内部的 RippleMarker 可用 bg-(--flow-tone) 取同一个颜色。
FlowTone | undefined图示说明;不传时作为装饰隐藏。内部不应放交互元素。
类型string | undefined默认 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
0 在上方,正值顺时针。
类型number | undefined相对最外圈半径的比例,0–1 位于覆盖范围内。
类型number | undefinedReact.ReactNodeangle 默认 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-diagramregistry 条目,安装时会一并提供;手动安装时需自行引入对应工具类和变量。
源码
"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 }