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.
Compiling the future
Customize
Usage
import { GlyphRain } from "@/components/motionkit/glyph-rain";
<GlyphRain
className="h-[32rem] rounded-3xl"
>
<h1>Your hero</h1>
</GlyphRain>Motion
The exact curves this component runs, plotted with Motion's own maths.
Rain
Runs on its own
Developer tools, security, AI and hackathon pages, and terminal-themed sections where code rain fits but the classic green would clash.
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.
Works in the App Router as is. Requires Tailwind CSS v4.
One command adds the file and installs its dependencies. Needs a components.json (run npx shadcn@latest init once if you don't have one).
npx shadcn@latest add https://www.motionkitui.com/r/glyph-rain.jsonnpm install motionSave as components/motionkit/glyph-rain.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>
);
}
| 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. |
How we audit: the WCAG 2.2 animation checklist and respecting reduced motion in React.
Yes. The file starts with "use client", so you can import it straight into a Server Component page or layout. It works in Vite as is, and in Astro as a React island.
Nothing beyond React and Tailwind CSS v4. There is no MotionKit package and no Tailwind config to add.
Yes. Run npx shadcn@latest add https://www.motionkitui.com/r/glyph-rain.json in any project with a components.json. It saves the file to components/motionkit and installs its dependencies.
With reduced motion on, it shows one settled frame of rain. It also stops drawing off screen.
Flicker Grid
A field of tiny squares that flicker softly, drawn on a canvas so it stays light.
Cursor Trail
A soft ribbon of light behind the pointer, across the window or inside one section.
Infinite Perspective Grid
A true 3D floor of glowing lines that recedes forever to a lit horizon, with an optional ceiling for a tunnel.