Data Rain
import { useEffect, useRef } from 'react';
const css = `
.btn-data-rain {
position: relative;
display: grid;
place-items: center;
width: 210px;
height: 60px;
padding: 0;
border: 1px solid light-dark(#dbdde1, #2e3035);
border-radius: 12px;
background: light-dark(#fbfcfd, #121417);
color: light-dark(#373a40, #f1f2f3);
font-family: inherit;
font-size: 14px;
font-weight: 600;
letter-spacing: 0.16em;
text-transform: uppercase;
cursor: pointer;
overflow: hidden;
transition: border-color 0.3s ease;
}
.btn-data-rain:hover { border-color: light-dark(#6d7179, #7d8087); }
.btn-data-rain:focus-visible { outline: 2px solid light-dark(#42454c, #e5e6e9); outline-offset: 3px; }
.btn-data-rain canvas { position: absolute; inset: 0; width: 100%; height: 100%; }
.btn-data-rain__label {
position: relative;
padding: 3px 10px;
border-radius: 6px;
background: color-mix(in oklab, light-dark(#fbfcfd, #121417) 80%, transparent);
box-shadow: 0 0 14px 6px color-mix(in oklab, light-dark(#fbfcfd, #121417) 80%, transparent);
}
`;
const GLYPHS = '01アイウエオカキクケコサシスセソ<>/{}=+*#ハミヒーウシナモニサワツオリ';
const COL = 11;
const FONT = 11;
export function DataRainButton() {
const btn = useRef<HTMLButtonElement>(null);
const canvas = useRef<HTMLCanvasElement>(null);
const raining = useRef(false);
useEffect(() => {
const el = canvas.current!;
const ctx = el.getContext('2d')!;
const w = el.clientWidth;
const h = el.clientHeight;
const dpr = Math.min(2, devicePixelRatio || 1);
el.width = w * dpr;
el.height = h * dpr;
ctx.scale(dpr, dpr);
const cols = Math.ceil(w / COL);
// Each column: head position (in rows), fall speed, and whether it is still pouring.
const drops = Array.from({ length: cols }, () => ({ y: -Math.random() * 10, v: 9 + Math.random() * 10, on: false }));
const still = matchMedia('(prefers-reduced-motion: reduce)').matches;
let last = performance.now();
let raf = 0;
const frame = (now: number) => {
const dt = Math.min(0.05, (now - last) / 1000);
last = now;
// Canvas can't read CSS variables: the ink is the button's own colour (per frame, so
// a theme switch recolours it).
ctx.fillStyle = getComputedStyle(btn.current!).color;
// Fade what is already drawn toward transparent, leaving a trail behind each head.
// destination-out only uses the source's alpha, so any colour does here.
ctx.globalCompositeOperation = 'destination-out';
ctx.globalAlpha = 0.14;
ctx.fillRect(0, 0, w, h);
ctx.globalCompositeOperation = 'source-over';
ctx.font = `${FONT}px ui-monospace, SFMono-Regular, Menlo, monospace`;
drops.forEach((d, i) => {
if (raining.current) d.on = true;
if (!d.on) return;
const before = Math.floor(d.y);
d.y += d.v * dt;
if (Math.floor(d.y) !== before && d.y > 0) {
ctx.globalAlpha = 0.95;
ctx.fillText(GLYPHS[(Math.random() * GLYPHS.length) | 0], i * COL + 1, d.y * FONT);
}
if (d.y * FONT > h + FONT) {
// Off the bottom: start again from the top, or stop if the hover has ended.
d.y = -Math.random() * 6;
d.v = 9 + Math.random() * 10;
d.on = raining.current;
}
});
ctx.globalAlpha = 1;
raf = requestAnimationFrame(frame);
};
if (!still) raf = requestAnimationFrame(frame);
return () => cancelAnimationFrame(raf);
}, []);
return (
<>
<style>
{css}
</style>
<button
ref={btn}
type="button"
className="btn-data-rain"
onPointerEnter={() => (raining.current = true)}
onPointerLeave={() => (raining.current = false)}
onFocus={() => (raining.current = true)}
onBlur={() => (raining.current = false)}
>
<canvas ref={canvas} aria-hidden="true" />
<span className="btn-data-rain__label">Decrypt</span>
</button>
</>
);
}Markup where the component goes, CSS in your stylesheet, script before </body>. In Vue, Svelte or Angular, call initBtnDataRain(el) on the mounted element instead.
<button type="button" class="btn-data-rain">
<canvas aria-hidden="true"></canvas>
<span class="btn-data-rain__label">Decrypt</span>
</button>.btn-data-rain {
position: relative;
display: grid;
place-items: center;
width: 210px;
height: 60px;
padding: 0;
border: 1px solid light-dark(#dbdde1, #2e3035);
border-radius: 12px;
background: light-dark(#fbfcfd, #121417);
color: light-dark(#373a40, #f1f2f3);
font-family: inherit;
font-size: 14px;
font-weight: 600;
letter-spacing: 0.16em;
text-transform: uppercase;
cursor: pointer;
overflow: hidden;
transition: border-color 0.3s ease;
}
.btn-data-rain:hover { border-color: light-dark(#6d7179, #7d8087); }
.btn-data-rain:focus-visible { outline: 2px solid light-dark(#42454c, #e5e6e9); outline-offset: 3px; }
.btn-data-rain canvas { position: absolute; inset: 0; width: 100%; height: 100%; }
.btn-data-rain__label {
position: relative;
padding: 3px 10px;
border-radius: 6px;
background: color-mix(in oklab, light-dark(#fbfcfd, #121417) 80%, transparent);
box-shadow: 0 0 14px 6px color-mix(in oklab, light-dark(#fbfcfd, #121417) 80%, transparent);
}function initBtnDataRain(root) {
var canvas = root.querySelector('canvas');
if (!canvas) return;
if (matchMedia('(prefers-reduced-motion: reduce)').matches) return;
var GLYPHS = '01アイウエオカキクケコサシスセソ<>/{}=+*#ハミヒーウシナモニサワツオリ';
var COL = 11;
var FONT = 11;
var ctx = canvas.getContext('2d');
var w = canvas.clientWidth;
var h = canvas.clientHeight;
var dpr = Math.min(2, devicePixelRatio || 1);
canvas.width = w * dpr;
canvas.height = h * dpr;
ctx.scale(dpr, dpr);
// Each column: head position (in rows), fall speed, and whether it is still pouring.
var drops = [];
for (var i = 0; i < Math.ceil(w / COL); i++) drops.push({ y: -Math.random() * 10, v: 9 + Math.random() * 10, on: false });
var raining = false;
var last = performance.now();
var raf = 0;
function frame(now) {
var dt = Math.min(0.05, (now - last) / 1000);
last = now;
ctx.fillStyle = getComputedStyle(root).color;
// Fade what is already drawn, leaving a trail behind each head (destination-out uses only alpha).
ctx.globalCompositeOperation = 'destination-out';
ctx.globalAlpha = 0.14;
ctx.fillRect(0, 0, w, h);
ctx.globalCompositeOperation = 'source-over';
ctx.font = FONT + 'px ui-monospace, SFMono-Regular, Menlo, monospace';
drops.forEach(function (d, c) {
if (raining) d.on = true;
if (!d.on) return;
var before = Math.floor(d.y);
d.y += d.v * dt;
if (Math.floor(d.y) !== before && d.y > 0) {
ctx.globalAlpha = 0.95;
ctx.fillText(GLYPHS[(Math.random() * GLYPHS.length) | 0], c * COL + 1, d.y * FONT);
}
if (d.y * FONT > h + FONT) {
// Off the bottom: start again from the top, or stop if the hover has ended.
d.y = -Math.random() * 6;
d.v = 9 + Math.random() * 10;
d.on = raining;
}
});
ctx.globalAlpha = 1;
raf = requestAnimationFrame(frame);
}
function on() { raining = true; }
function off() { raining = false; }
root.addEventListener('pointerenter', on);
root.addEventListener('pointerleave', off);
root.addEventListener('focus', on);
root.addEventListener('blur', off);
raf = requestAnimationFrame(frame);
return function () {
cancelAnimationFrame(raf);
root.removeEventListener('pointerenter', on);
root.removeEventListener('pointerleave', off);
root.removeEventListener('focus', on);
root.removeEventListener('blur', off);
};
}
document.querySelectorAll('.btn-data-rain').forEach(initBtnDataRain);Goal: Create a "Data Rain" button.
Visual behavior: Hover and columns of glyphs start pouring down behind the label, each column at its own speed with a fading trail. Leave and the columns finish their fall and trickle out one by one.
Technique:
- A canvas behind the label is split into 11px columns, each a drop with a head position, a speed and an on flag.
- Each frame fades what is already drawn with destination-out at 14% alpha (only the alpha matters), which leaves a trail behind every head.
- A random glyph (digits, katakana, symbols) is drawn whenever a head enters a new row; heads that leave the bottom restart from the top while hovering, or stop.
- The ink is the button's computed colour, so the rain follows the theme; the label sits on a soft dark plate.
Accessibility: A real <button> with a visible label; decoration is aria-hidden. Focus starts the rain like hover. Under prefers-reduced-motion nothing is drawn.
My stack: {{USER_STACK}}
My styling: {{USER_STYLING}}
My constraints: {{USER_CONSTRAINTS}}
My colors: neutral only. Black, white and grays, no hues. Use the color values above as written, or my project's closest neutral tokens.
Return a single self-contained component. No dependencies.Goal: Create a "Data Rain" button.
Visual behavior: Hover and columns of glyphs start pouring down behind the label, each column at its own speed with a fading trail. Leave and the columns finish their fall and trickle out one by one.
Technique:
- A canvas behind the label is split into 11px columns, each a drop with a head position, a speed and an on flag.
- Each frame fades what is already drawn with destination-out at 14% alpha (only the alpha matters), which leaves a trail behind every head.
- A random glyph (digits, katakana, symbols) is drawn whenever a head enters a new row; heads that leave the bottom restart from the top while hovering, or stop.
- The ink is the button's computed colour, so the rain follows the theme; the label sits on a soft dark plate.
Accessibility: A real <button> with a visible label; decoration is aria-hidden. Focus starts the rain like hover. Under prefers-reduced-motion nothing is drawn.
My stack: {{USER_STACK}}
My styling: {{USER_STYLING}}
My constraints: {{USER_CONSTRAINTS}}
My colors: use my project's existing color tokens. The color values above are grayscale placeholders. Map each one by its role (surface, text, border, accent, glow) to my theme, and keep their relative lightness so contrast and the effect still read.
Return a single self-contained component. No dependencies.Paste into Claude, ChatGPT or Cursor after filling in {{USER_STACK}}, {{USER_STYLING}} and {{USER_CONSTRAINTS}}. Neutral keeps the greys you see here; Match my project asks the model to map them onto your own colour tokens.
Free to use in any project, commercial included. MIT-0, no attribution required. Licence
About this component
Hover and columns of glyphs start pouring down behind the label, each column at its own speed with a fading trail. Leave and the columns finish their fall and trickle out one by one.
- Dependencies
- None
- Difficulty
- Intermediate
- Triggers
- hover, continuous, glow
- Techniques
- raf, text animation
- Reduced motion
- Respected
- Theme
- Light and dark (follows your page's color-scheme)
A real <button> with a visible label; decoration is aria-hidden. Focus starts the rain like hover. Under prefers-reduced-motion nothing is drawn.