字符雨

一列列字符以不同速度从屏幕上方流下,每一列明亮的头部后面拖着逐渐消失的尾巴。

  • Canvas
  • 背景
  • 循环
  • 收录于
实时信号

AI 提示词

用 canvas 制作下落的字符雨,不使用第三方库。用 devicePixelRatio 和 ResizeObserver 让 canvas 匹配容器尺寸。使用 14px 等宽字体,每 14px 一列;每列保存一个 y 位置和一个随机速度(0.4 到 1)。每一帧先用 12% 不透明度的背景色填满整个 canvas,让之前的字符淡出成尾巴,然后在每列的头部用主色绘制一个从字符池“动效光影节奏速度弧线弹性渐变层次空间时间”中随机选出的字符(最前面的字符使用更亮的文字色)。每隔几帧让每列前进“速度 × 字号”,越过底部后按随机概率重置到顶部,让各列保持错开。颜色从 CSS 自定义属性读取。离开屏幕时用 IntersectionObserver 暂停循环,在 prefers-reduced-motion 下只绘制一帧静态画面。组件卸载时清理观察器和动画帧。

源代码

Demo.tsx
'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>
  )
}
demo.module.css
.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;
}

投稿创意

看到想收录的动效或主题?简单描述一下,会打开一个预先填好的 GitHub Issue,确认后即可提交。

你想投稿的是?

需要 GitHub 账号,投稿内容会公开。