Glyph Rain

Columns of characters stream down the screen at different speeds, each leaving a fading tail behind its bright head.

  • Canvas
  • Backgrounds
  • Loop
  • Added
Live feed

AI prompt

Create a falling glyph rain on a canvas element with no libraries. Size the canvas to its container with devicePixelRatio and a ResizeObserver. Use a 14px monospace font and one column per 14px; keep a y position and a random speed (0.4 to 1) per column. Each frame, fill the whole canvas with the background colour at 12% opacity so earlier glyphs fade into a tail, then draw a random glyph from the pool "アイウエオカキクケコ0123456789<>/*+=" at each column head in the accent colour (and the leading character in a lighter ink). Advance each column by its speed × font size every few frames, and when it passes the bottom, reset it to the top with a random chance so columns stay out of sync. Read colours from CSS custom properties. Pause the loop with an IntersectionObserver when off screen, and under prefers-reduced-motion draw a single static frame. Clean up observers and the animation frame on unmount.

Source

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;
}

Suggest an idea

Seen a motion or a theme you would like to find here? Describe it, and it opens as a prefilled GitHub issue for you to post.

What are you suggesting?

Needs a GitHub account. Suggestions are public.