# Glyph Rain

> Columns of code characters raining down, each led by a brighter glyph and fading into the dark. A calmer, on-brand take on code rain.

Category: Backgrounds · Free · Updated 2026-10-26 · Live demo: https://www.motionkitui.com/components/glyph-rain

## When to use it

Developer tools, security, AI and hackathon pages, and terminal-themed sections where code rain fits but the classic green would clash.

## How it works

Each column has a drop that falls a row at a time at its own rate. Rather than clearing, every frame paints the background over everything at low opacity, so older glyphs fade into trails. When a drop moves down a row, the glyph it leaves becomes trail-coloured and a bright leading glyph is drawn below. Glyphs are picked with an integer hash, so a still frame for reduced motion is the same every time, and columns are dimmed through the middle so your content stays readable.

## Installation

```bash
npx shadcn@latest add https://www.motionkitui.com/r/glyph-rain.json
```

Or copy `components/motionkit/glyph-rain.tsx` below. Requires react and Tailwind CSS v4. Free for personal projects; commercial use needs MotionKit Plus (https://www.motionkitui.com/pricing).

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `colors` | `[string, string]` | `["#0E7C86", "#93CACE"]` | Trail and leading glyph colours. |
| `glyphs` | `"code" \| "binary" \| "katakana"` | `"code"` | Which characters fall. |
| `size` | `number` | `15` | Glyph size in pixels. |
| `speed` | `number` | `1` | Fall speed. 0 holds it still. |
| `children` | `ReactNode` |  | Your content, layered above. |

## Accessibility

- **Keyboard: Not applicable.** Decorative, nothing to focus.
- **Screen readers: Pass.** The canvas is hidden from assistive tech; only your content is read.
- **Reduced motion: Pass.** With reduced motion on, it shows one settled frame of rain. It also stops drawing off screen.
- **Colour contrast: Pass.** Columns fade through the middle, so ivory text there measures 14.3:1 or better.

## Source

```tsx
"use client";

import { useEffect, useRef, type ReactNode } from "react";

type GlyphRainProps = {
  children?: ReactNode;
  /** Colours of the falling trails and of each column's leading glyph. */
  colors?: [string, string];
  /** Which characters fall. */
  glyphs?: "code" | "binary" | "katakana";
  /** Size of each glyph, in pixels. */
  size?: number;
  /** Fall speed. 0 holds it still. */
  speed?: number;
  className?: string;
};

const sets = {
  code: "{}[]()<>/=+-*;:.$#@&|!?01ABCDEFabcdef",
  binary: "01",
  katakana: "アイウエオカキクケコサシスセソタチツテトナニヌネノハヒフヘホマミムメモヤユヨラリルレロワン0123456789",
};

function toRgb(hex: string): [number, number, number] {
  const value = hex.replace("#", "");
  const full = value.length === 3 ? [...value].map((c) => c + c).join("") : value;
  const number = parseInt(full, 16);
  return [(number >> 16) & 255, (number >> 8) & 255, number & 255];
}

/**
 * Columns of characters raining down the screen, each led by a brighter glyph and trailing off into
 * the dark. A calmer, on-brand take on the classic code rain, with a quiet centre for your content.
 */
export function GlyphRain({ children, colors = ["#0E7C86", "#93CACE"], glyphs = "code", size = 15, speed = 1, className = "" }: GlyphRainProps) {
  const canvasRef = useRef<HTMLCanvasElement>(null);

  useEffect(() => {
    const canvas = canvasRef.current;
    const context = canvas?.getContext("2d");
    if (!canvas || !context) return;
    const [trail, head] = colors.map(toRgb);
    const chars = sets[glyphs];
    const still = speed === 0 || window.matchMedia("(prefers-reduced-motion: reduce)").matches;
    let width = 0;
    let height = 0;
    let frame = 0;
    let visible = true;
    let last = 0;
    let drops: { y: number; rate: number; seed: number }[] = [];
    // an integer hash picks glyphs, so the still frame for reduced motion is the same every time
    const glyph = (n: number) => chars[(Math.imul(n ^ 0x9e3779b9, 0x85ebca6b) >>> 0) % chars.length];

    function resize() {
      const rect = canvas!.getBoundingClientRect();
      const ratio = Math.min(window.devicePixelRatio || 1, 2);
      width = rect.width;
      height = rect.height;
      canvas!.width = width * ratio;
      canvas!.height = height * ratio;
      context!.setTransform(ratio, 0, 0, ratio, 0, 0);
      const columns = Math.ceil(width / size);
      drops = Array.from({ length: columns }, (_, index) => drops[index] ?? { y: -((index * 7919) % 40), rate: 0.6 + ((index * 104729) % 100) / 100, seed: index * 131 });
      context!.fillStyle = "#0C1010";
      context!.fillRect(0, 0, width, height);
    }

    function draw(delta: number) {
      // painting the background at low opacity, rather than clearing, is what leaves the fading trails
      context!.fillStyle = "rgb(12 16 16 / 0.14)";
      context!.fillRect(0, 0, width, height);
      context!.font = `${size}px ui-monospace, SFMono-Regular, Menlo, monospace`;
      context!.textAlign = "center";
      drops.forEach((drop, column) => {
        const previous = Math.floor(drop.y);
        drop.y += delta * speed * 9 * drop.rate;
        const row = Math.floor(drop.y);
        if (row === previous && delta) return;
        const x = column * size + size / 2;
        const y = row * size;
        // the soft dark centre: columns crossing the middle are drawn fainter
        const cx = x / width - 0.5;
        const cy = y / height - 0.5;
        const calm = 0.25 + 0.75 * Math.min(1, (cx * cx * 1.6 + cy * cy) * 4.5);
        context!.fillStyle = `rgb(${trail[0]} ${trail[1]} ${trail[2]} / ${0.75 * calm})`;
        context!.fillText(glyph(drop.seed + row - 1), x, y - size);
        context!.fillStyle = `rgb(${head[0]} ${head[1]} ${head[2]} / ${calm})`;
        context!.fillText(glyph(drop.seed + row), x, y);
        if (y > height && (drop.seed * 7 + row) % 9 === 0) {
          drop.y = -((drop.seed + row) % 20);
          drop.seed += 977;
        }
      });
    }

    function loop(time: number) {
      const delta = last ? Math.min(0.05, (time - last) / 1000) : 0;
      last = time;
      draw(delta);
      frame = visible ? requestAnimationFrame(loop) : 0;
      if (!frame) last = 0;
    }

    resize();
    if (still) {
      // a settled frame: run the rain for a while off screen, then show it
      for (let step = 0; step < 120; step++) draw(1 / 30);
    } else frame = requestAnimationFrame(loop);
    const resizeObserver = new ResizeObserver(() => resize());
    resizeObserver.observe(canvas);
    const visibilityObserver = new IntersectionObserver(([entry]) => {
      visible = entry.isIntersecting;
      if (visible && !frame && !still) frame = requestAnimationFrame(loop);
    });
    visibilityObserver.observe(canvas);

    return () => {
      cancelAnimationFrame(frame);
      resizeObserver.disconnect();
      visibilityObserver.disconnect();
    };
    // the colours are compared by value, so a new array with the same colours doesn't restart it
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [colors.join(), glyphs, size, speed]);

  return (
    <div className={`relative isolate overflow-hidden bg-[#0C1010] ${className}`}>
      <canvas ref={canvasRef} aria-hidden className="absolute inset-0 -z-10 size-full" />
      {children}
    </div>
  );
}
```
