字符雨
一列列字符以不同速度从屏幕上方流下,每一列明亮的头部后面拖着逐渐消失的尾巴。
实时信号
用 canvas 制作下落的字符雨,不使用第三方库。用 devicePixelRatio 和 ResizeObserver 让 canvas 匹配容器尺寸。使用 14px 等宽字体,每 14px 一列;每列保存一个 y 位置和一个随机速度(0.4 到 1)。每一帧先用 12% 不透明度的背景色填满整个 canvas,让之前的字符淡出成尾巴,然后在每列的头部用主色绘制一个从字符池“动效光影节奏速度弧线弹性渐变层次空间时间”中随机选出的字符(最前面的字符使用更亮的文字色)。每隔几帧让每列前进“速度 × 字号”,越过底部后按随机概率重置到顶部,让各列保持错开。颜色从 CSS 自定义属性读取。离开屏幕时用 IntersectionObserver 暂停循环,在 prefers-reduced-motion 下只绘制一帧静态画面。组件卸载时清理观察器和动画帧。
源代码
'use client'
import { useEffect, useRef } from 'react'
import s from './demo.module.css'
const SIZE = 14
export default function MatrixRain({ labels }: { labels: Record<string, string> }) {
const ref = useRef<HTMLCanvasElement>(null)
useEffect(() => {
const canvas = ref.current!
const ctx = canvas.getContext('2d')!
const css = getComputedStyle(canvas)
const bg = css.getPropertyValue('--bg').trim()
const accent = css.getPropertyValue('--accent').trim()
const head = css.getPropertyValue('--head').trim()
const glyphs = Array.from(labels.glyphs)
const pick = () => glyphs[Math.floor(Math.random() * glyphs.length)]
const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches
let cols: { y: number; speed: number }[] = []
let w = 0
let h = 0
let raf = 0
let frame = 0
let visible = true
const resize = () => {
const dpr = window.devicePixelRatio || 1
w = canvas.clientWidth
h = canvas.clientHeight
canvas.width = w * dpr
canvas.height = h * dpr
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
ctx.font = `${SIZE}px ui-monospace, monospace`
ctx.textBaseline = 'top'
cols = Array.from({ length: Math.ceil(w / SIZE) }, () => ({
y: Math.random() * (h / SIZE),
speed: 0.4 + Math.random() * 0.6,
}))
ctx.fillStyle = bg
ctx.fillRect(0, 0, w, h)
if (reduced) staticFrame()
}
const step = () => {
ctx.globalAlpha = 0.12
ctx.fillStyle = bg
ctx.fillRect(0, 0, w, h)
ctx.globalAlpha = 1
cols.forEach((c, i) => {
const y = Math.floor(c.y) * SIZE
ctx.fillStyle = accent
ctx.fillText(pick(), i * SIZE, y - SIZE)
ctx.fillStyle = head
ctx.fillText(pick(), i * SIZE, y)
c.y += c.speed
if (y > h && Math.random() > 0.96) c.y = 0
})
}
const staticFrame = () => {
for (let n = 0; n < 40; n++) step()
}
const loop = () => {
if (frame++ % 3 === 0) step()
if (visible) raf = requestAnimationFrame(loop)
}
const ro = new ResizeObserver(resize)
ro.observe(canvas)
const io = new IntersectionObserver(([e]) => {
visible = e.isIntersecting && !reduced
cancelAnimationFrame(raf)
if (visible) raf = requestAnimationFrame(loop)
})
io.observe(canvas)
return () => {
cancelAnimationFrame(raf)
ro.disconnect()
io.disconnect()
}
}, [labels.glyphs])
return (
<div className={s.wrap}>
<canvas ref={ref} className={s.canvas} aria-hidden="true" />
<span className={s.caption}>{labels.caption}</span>
</div>
)
}
.wrap {
position: absolute;
inset: 0;
display: grid;
place-items: center;
background: var(--stage-bg);
}
.canvas {
--bg: var(--stage-bg);
--accent: var(--stage-accent);
--head: var(--stage-ink);
position: absolute;
inset: 0;
width: 100%;
height: 100%;
}
.caption {
position: relative;
padding: 6px 10px;
border-radius: 6px;
background: var(--stage-bg);
font: 500 var(--text-xs) / 1 var(--font-mono);
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--stage-ink);
pointer-events: none;
}