彩纸爆发
点击后喷出一束彩纸,彩纸翻滚着因空气阻力减速,然后落出画面。
用 React 和 <canvas> 覆盖层制作一个不依赖第三方库的彩纸爆发效果。点击时,在按钮中心生成 90 片彩纸,每片的方向在朝上的 70 度锥形内随机,速度为每帧 6–12px,尺寸 6×10px,旋转速度随机,颜色从 4 种品牌色中选取。每次 requestAnimationFrame:施加重力(vy 加 0.25)、空气阻力(vx 和 vy 乘以 0.985),推进旋转,并把每片彩纸画成旋转的矩形,宽度乘以 Math.cos(tilt),看起来像在翻面。移除离开画布的彩纸,当没有彩纸时停止循环。按 devicePixelRatio 设置画布尺寸,并设置 pointer-events: none。在 prefers-reduced-motion 下不播放彩纸,改为让按钮做一次 150ms 的透明度闪动。按钮文字为“庆祝一下”。
源代码
'use client'
import { useEffect, useRef } from 'react'
import s from './demo.module.css'
type Piece = { x: number; y: number; vx: number; vy: number; rot: number; vr: number; tilt: number; color: string }
export default function ConfettiBurst({ labels }: { labels: Record<string, string> }) {
const canvas = useRef<HTMLCanvasElement>(null)
const button = useRef<HTMLButtonElement>(null)
const pieces = useRef<Piece[]>([])
const raf = useRef(0)
useEffect(() => () => cancelAnimationFrame(raf.current), [])
const fire = () => {
const c = canvas.current!
const b = button.current!
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
b.animate([{ opacity: 0.4 }, { opacity: 1 }], 150)
return
}
const ctx = c.getContext('2d')!
const dpr = window.devicePixelRatio || 1
const w = c.clientWidth
const h = c.clientHeight
c.width = w * dpr
c.height = h * dpr
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
const css = getComputedStyle(c)
const palette = ['--c1', '--c2', '--c3', '--c4'].map((v) => css.getPropertyValue(v).trim())
const cr = c.getBoundingClientRect()
const br = b.getBoundingClientRect()
const ox = br.left + br.width / 2 - cr.left
const oy = br.top + br.height / 2 - cr.top
for (let i = 0; i < 90; i++) {
const angle = -Math.PI / 2 + (Math.random() - 0.5) * ((70 * Math.PI) / 180)
const speed = 6 + Math.random() * 6
pieces.current.push({
x: ox,
y: oy,
vx: Math.cos(angle) * speed,
vy: Math.sin(angle) * speed,
rot: Math.random() * Math.PI,
vr: (Math.random() - 0.5) * 0.3,
tilt: Math.random() * Math.PI,
color: palette[i % palette.length],
})
}
cancelAnimationFrame(raf.current)
const tick = () => {
ctx.clearRect(0, 0, w, h)
pieces.current = pieces.current.filter((p) => p.y < h + 20 && p.x > -20 && p.x < w + 20)
for (const p of pieces.current) {
p.vy += 0.25
p.vx *= 0.985
p.vy *= 0.985
p.x += p.vx
p.y += p.vy
p.rot += p.vr
p.tilt += 0.12
ctx.save()
ctx.translate(p.x, p.y)
ctx.rotate(p.rot)
ctx.fillStyle = p.color
ctx.fillRect(-3 * Math.cos(p.tilt), -5, 6 * Math.cos(p.tilt), 10)
ctx.restore()
}
if (pieces.current.length) raf.current = requestAnimationFrame(tick)
}
raf.current = requestAnimationFrame(tick)
}
return (
<div className={s.wrap}>
<canvas ref={canvas} className={s.canvas} aria-hidden="true" />
<button ref={button} type="button" className={s.button} onClick={fire}>
{labels.cta}
</button>
</div>
)
}
.wrap {
position: absolute;
inset: 0;
display: grid;
place-items: end center;
padding-bottom: 36px;
}
.canvas {
--c1: var(--stage-accent);
--c2: var(--stage-ink);
--c3: oklch(78% 0.12 85);
--c4: oklch(62% 0.1 160);
position: absolute;
inset: 0;
width: 100%;
height: 100%;
pointer-events: none;
}
.button {
position: relative;
height: 48px;
padding: 0 24px;
border: 0;
border-radius: 12px;
background: var(--stage-ink);
color: var(--stage-bg);
font: 600 var(--text-base) / 1 var(--font-body);
white-space: nowrap;
cursor: pointer;
}
.button:hover { background: var(--stage-accent); color: var(--stage-accent-ink); }
.button:active { transform: translateY(1px); }
.button:focus-visible { outline: 2px solid var(--color-focus); outline-offset: 3px; }