组件
庆祝纸屑 Confetti
用于在用户完成关键目标、解锁成就或支付成功时触发轻量高性能的 Canvas 粒子庆祝特效。
基础示例 · Basic example
Loading…
pnpm dlx @wui-design/cli@latest add @wui/confetti"use client"
import * as React from "react"
import { useReducedMotion } from "motion/react"
import { cn } from "@/lib/utils"
export type ConfettiShape = "square" | "circle" | "strip"
export interface ConfettiOptions {
/** 粒子数量。 @default 60 */
particleCount?: number
/** 扩散角度范围(0 ~ 360 度)。 @default 70 */
spread?: number
/** 发射方向角度,90 为正上方,0 为向右,180 为向左。 @default 90 */
angle?: number
/** 发射原点坐标 [0.0 ~ 1.0],相对视口。 @default { x: 0.5, y: 0.65 } */
origin?: { x?: number; y?: number }
/** 粒子颜色数组。 */
colors?: string[]
/** 粒子形状,随机从数组中取值。 @default ["square", "strip", "circle"] */
shapes?: ConfettiShape[]
/** 粒子尺寸缩放系数。 @default 1 */
scalar?: number
/** 初始发射速度(像素 / 帧,以 60fps 为基准)。 @default 40 */
startVelocity?: number
/** 重力加速度。 @default 1.2 */
gravity?: number
/** 每帧速度衰减系数(空气阻力),越小停得越快。 @default 0.92 */
decay?: number
/** 横向漂移,正值向右飘。 @default 0 */
drift?: number
/** @deprecated 未使用,请改用 `decay`。 */
ticks?: number
/** 持续时间(毫秒),粒子在此期间逐渐淡出。 @default 2500 */
duration?: number
/** 画布层级。 @default 9999 */
zIndex?: number
/** 用户开启“减少动态效果”时不播放。 @default true */
disableForReducedMotion?: boolean
}
interface Particle {
x: number
y: number
vx: number
vy: number
size: number
color: string
shape: ConfettiShape
rotation: number
rotationSpeed: number
tilt: number
tiltSpeed: number
wobble: number
wobbleSpeed: number
}
const DEFAULT_COLORS = [
"#2563eb", // wui-token-audit-allow
"#7c3aed", // wui-token-audit-allow
"#db2777", // wui-token-audit-allow
"#ea580c", // wui-token-audit-allow
"#16a34a", // wui-token-audit-allow
"#eab308", // wui-token-audit-allow
]
const DEFAULT_SHAPES: ConfettiShape[] = ["square", "strip", "circle"]
/**
* 触发全屏五彩纸屑庆祝动效。物理计算按真实帧间隔归一化,在 60Hz 与 120Hz
* 屏幕上速度一致。返回一个可提前停止并清理画布的函数。
*/
export function fireConfetti(options: ConfettiOptions = {}) {
const noop = () => {}
if (typeof window === "undefined") return noop
const {
particleCount = 60,
spread = 70,
angle = 90,
origin = { x: 0.5, y: 0.65 },
colors = DEFAULT_COLORS,
shapes = DEFAULT_SHAPES,
scalar = 1,
startVelocity = 40,
gravity = 1.2,
decay = 0.92,
drift = 0,
duration = 2500,
zIndex = 9999,
disableForReducedMotion = true,
} = options
if (
disableForReducedMotion &&
window.matchMedia("(prefers-reduced-motion: reduce)").matches
) {
return noop
}
const width = window.innerWidth
const height = window.innerHeight
const ox = (origin.x ?? 0.5) * width
const oy = (origin.y ?? 0.65) * height
const canvas = document.createElement("canvas")
canvas.setAttribute("aria-hidden", "true")
Object.assign(canvas.style, {
position: "fixed",
inset: "0",
width: "100vw",
height: "100vh",
pointerEvents: "none",
zIndex: String(zIndex),
})
document.body.appendChild(canvas)
const ctx = canvas.getContext("2d")
if (!ctx) {
canvas.remove()
return noop
}
const dpr = window.devicePixelRatio || 1
canvas.width = width * dpr
canvas.height = height * dpr
ctx.scale(dpr, dpr)
const radSpread = (spread * Math.PI) / 180
const baseAngle = (-angle * Math.PI) / 180
const particles: Particle[] = Array.from({ length: particleCount }, () => {
const direction = baseAngle + (Math.random() - 0.5) * radSpread
const velocity = startVelocity * (0.55 + Math.random() * 0.6)
return {
x: ox,
y: oy,
vx: Math.cos(direction) * velocity,
vy: Math.sin(direction) * velocity,
size: (Math.random() * 5 + 6) * scalar,
color: colors[Math.floor(Math.random() * colors.length)] ?? "#2563eb", // wui-token-audit-allow
shape: shapes[Math.floor(Math.random() * shapes.length)] ?? "square",
rotation: Math.random() * Math.PI * 2,
rotationSpeed: (Math.random() - 0.5) * 0.3,
tilt: Math.random() * Math.PI * 2,
tiltSpeed: 0.08 + Math.random() * 0.12,
wobble: Math.random() * Math.PI * 2,
wobbleSpeed: 0.05 + Math.random() * 0.08,
}
})
let frame = 0
let stopped = false
const startTime = performance.now()
let lastTime = startTime
const cleanup = () => {
stopped = true
cancelAnimationFrame(frame)
canvas.remove()
}
const render = (now: number) => {
if (stopped) return
// Normalise to 60fps steps so high refresh-rate screens are not faster.
const step = Math.min((now - lastTime) / (1000 / 60), 3)
lastTime = now
const elapsed = now - startTime
const fade = Math.max(0, 1 - Math.max(0, elapsed - duration * 0.6) / (duration * 0.4))
const drag = Math.pow(decay, step)
ctx.clearRect(0, 0, width, height)
let alive = 0
for (const p of particles) {
p.vx = p.vx * drag + drift * 0.05 * step
p.vy = p.vy * drag + gravity * step
p.wobble += p.wobbleSpeed * step
p.x += (p.vx + Math.cos(p.wobble) * 1.2) * step
p.y += p.vy * step
p.rotation += p.rotationSpeed * step
p.tilt += p.tiltSpeed * step
if (p.y > height + 40 || fade <= 0) continue
alive++
ctx.save()
ctx.translate(p.x, p.y)
ctx.rotate(p.rotation)
// Squash one axis over time so flat pieces appear to flip in 3D.
ctx.scale(1, Math.cos(p.tilt))
ctx.globalAlpha = fade
ctx.fillStyle = p.color
if (p.shape === "circle") {
ctx.beginPath()
ctx.arc(0, 0, p.size * 0.4, 0, Math.PI * 2)
ctx.fill()
} else if (p.shape === "strip") {
ctx.fillRect(-p.size * 0.15, -p.size * 0.6, p.size * 0.3, p.size * 1.2)
} else {
ctx.fillRect(-p.size / 2, -p.size * 0.3, p.size, p.size * 0.6)
}
ctx.restore()
}
if (alive > 0 && elapsed < duration) {
frame = requestAnimationFrame(render)
} else {
cleanup()
}
}
frame = requestAnimationFrame(render)
return cleanup
}
export interface ConfettiProps extends React.ComponentProps<"div"> {
/** 挂载时使用的纸屑粒子选项。 */
options?: ConfettiOptions
/** 是否在组件挂载时立即触发一次。 @default false */
fireOnMount?: boolean
}
/** 五彩纸屑庆祝动效组件,支持声明式渲染与命令式函数触发。 */
function Confetti({
className,
options,
fireOnMount = false,
children,
...props
}: ConfettiProps) {
const reduceMotion = useReducedMotion()
const optionsRef = React.useRef(options)
React.useEffect(() => {
optionsRef.current = options
})
React.useEffect(() => {
if (!fireOnMount || reduceMotion) return
return fireConfetti(optionsRef.current)
}, [fireOnMount, reduceMotion])
return (
<div data-slot="confetti" className={cn("inline-block", className)} {...props}>
{children}
</div>
)
}
export { Confetti }
组件作用 · What it's for
Confetti 为核心里程碑操作提供具有成就感的即时视觉反馈。
适用场景
- 目标达成与等级提升:用户完成挑战、表单提交成功或积分升级。
- 支付与购买成功:电商结算或 SaaS 订阅完成时的庆祝。
- 免除外部冗余依赖:纯原生 Canvas 与 requestAnimationFrame 实现,轻量零负担。
何时不建议使用
- 避免在常规高频操作(如普通按钮点击或翻页)中滥用,以免分散用户注意力。
组件属性 · Props
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| options | ConfettiOptions | — | 挂载时使用的纸屑粒子选项。 |
| fireOnMount | boolean | false | 是否在组件挂载时立即触发一次。 |
事件 · Events
- 支持通过
fireConfetti({ particleCount, spread, origin })随时在任意异步回调中命令式触发。 fireConfetti返回一个清理函数,调用后立即停止动画并移除画布,适合在组件卸载时调用。
拓展使用 · Extended usage
可自定义 colors 传入品牌主题色,或控制 origin 与 angle 实现从按钮位置、屏幕两侧或底部发射粒子。粒子物理按真实帧间隔归一化,在 60Hz 与 120Hz 屏幕上速度一致;每片纸屑会随机翻转与摆动,模拟真实飘落。
ConfettiOptions
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| particleCount | number | 60 | 粒子数量。 |
| spread | number | 70 | 扩散角度范围(度)。 |
| angle | number | 90 | 发射方向,90 为正上方,60 偏右上,120 偏左上。 |
| origin | { x?: number; y?: number } | { x: 0.5, y: 0.65 } | 发射原点,取值 0 ~ 1,相对视口。可由按钮的 getBoundingClientRect 计算。 |
| colors | string[] | — | 粒子颜色,随机取用。 |
| shapes | ("square" | "circle" | "strip")[] | ["square", "strip", "circle"] | 粒子形状,strip 为细长彩带。 |
| scalar | number | 1 | 粒子尺寸缩放系数。 |
| startVelocity | number | 40 | 初始速度(以 60fps 每帧像素计)。 |
| gravity | number | 1.2 | 重力加速度。 |
| decay | number | 0.92 | 每帧速度衰减(空气阻力),越小越快停下。 |
| drift | number | 0 | 横向漂移,正值向右飘。 |
| duration | number | 2500 | 持续时间(毫秒),末段逐渐淡出。 |
| zIndex | number | 9999 | 画布层级。 |
| disableForReducedMotion | boolean | true | 用户开启“减少动态效果”时不播放。 |