25 CSS Background Animations20 / 25
Matrix Digital Rain Effect
Digital rain done right: a bright leading glyph, a decaying trail behind it, per-column speeds, occasional glyph mutation mid-fall, and a frame budget that holds at 4K. It runs at a deliberate 24fps — the film frame rate the effect is remembered from — which also cuts its CPU cost by more than half compared to a 60fps loop nobody can perceive as smoother.
Published Updated
The code
<section class="bga-20" id="bga-20-root" aria-label="Matrix digital rain background demo">
<canvas class="bga-20__c" id="bga-20-canvas" aria-hidden="true"></canvas>
<div class="bga-20__vig" aria-hidden="true"></div>
<div class="bga-20__stage">
<div class="bga-20__panel">
<p class="bga-20__eyebrow">24 fps by choice · 20</p>
<h1 class="bga-20__title">Bright head.<br>Decaying tail.</h1>
<p class="bga-20__sub">The head/tail luminance split is what makes digital rain read as rain. Everything else — glyph set, colour, speed — is set dressing.</p>
<div class="bga-20__readout">
<span><i></i>columns <b id="bga-20-cols">—</b></span>
<span><i></i>fps cap <b>24</b></span>
<span><i></i>off-screen <b>paused</b></span>
</div>
</div>
</div>
</section><section class="bga-20" id="bga-20-root" aria-label="Matrix digital rain background demo">
<canvas class="bga-20__c" id="bga-20-canvas" aria-hidden="true"></canvas>
<div class="bga-20__vig" aria-hidden="true"></div>
<div class="bga-20__stage">
<div class="bga-20__panel">
<p class="bga-20__eyebrow">24 fps by choice · 20</p>
<h1 class="bga-20__title">Bright head.<br>Decaying tail.</h1>
<p class="bga-20__sub">The head/tail luminance split is what makes digital rain read as rain. Everything else — glyph set, colour, speed — is set dressing.</p>
<div class="bga-20__readout">
<span><i></i>columns <b id="bga-20-cols">—</b></span>
<span><i></i>fps cap <b>24</b></span>
<span><i></i>off-screen <b>paused</b></span>
</div>
</div>
</div>
</section>.bga-20,
.bga-20 *,
.bga-20 *::before,
.bga-20 *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
.bga-20 {
width: 100%;
min-height: 100vh;
min-height: 100svh;
display: block;
position: relative;
overflow: hidden;
isolation: isolate;
--bga-20-bg: oklch(0.13 0.03 158);
--bga-20-ph: oklch(0.86 0.2 152);
background: var(--bga-20-bg);
color: var(--bga-20-ph);
font-family: ui-monospace,'SFMono-Regular',Menlo,Consolas,monospace;
-webkit-font-smoothing: antialiased;
}
.bga-20__c {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
display: block;
}
.bga-20__vig {
position: absolute;
inset: 0;
pointer-events: none;
background: radial-gradient(110% 88% at 50% 46%,transparent 34%,oklch(0.07 0.02 158/.92) 100%);
}
.bga-20__stage {
position: relative;
z-index: 2;
min-height: 100vh;
min-height: 100svh;
display: grid;
place-items: center;
align-content: center;
padding: clamp(22px,5vw,68px);
}
.bga-20__panel {
width: min(100%,640px);
display: grid;
gap: 15px;
justify-items: center;
text-align: center;
padding: clamp(26px,3.8vw,46px);
background: oklch(0.1 0.025 158/.7);
border: 1px solid color-mix(in oklab,var(--bga-20-ph) 26%,transparent);
box-shadow: 0 0 60px -20px color-mix(in oklab,var(--bga-20-ph) 40%,transparent),0 40px 90px -50px oklch(0 0 0/.95);
}
.bga-20__eyebrow {
font-size: 11.5px;
letter-spacing: .24em;
text-transform: uppercase;
color: color-mix(in oklab,var(--bga-20-ph) 70%,transparent);
}
.bga-20__title {
font-size: clamp(28px,5.6vw,56px);
font-weight: 700;
line-height: 1.02;
letter-spacing: -.03em;
text-wrap: balance;
text-shadow: 0 0 28px color-mix(in oklab,var(--bga-20-ph) 55%,transparent);
}
.bga-20__sub {
max-width: 48ch;
font-family: ui-sans-serif,system-ui,-apple-system,sans-serif;
font-size: clamp(13.5px,1.3vw,16px);
line-height: 1.6;
color: color-mix(in oklab,var(--bga-20-ph) 78%,transparent);
text-wrap: pretty;
}
.bga-20__readout {
display: flex;
flex-wrap: wrap;
gap: 8px;
justify-content: center;
margin-top: 4px;
}
.bga-20__readout span {
display: inline-flex;
align-items: center;
gap: 8px;
font-size: 11px;
letter-spacing: .06em;
text-transform: uppercase;
padding: 8px 13px;
color: color-mix(in oklab,var(--bga-20-ph) 66%,transparent);
background: oklch(1 0 0/.04);
border: 1px solid color-mix(in oklab,var(--bga-20-ph) 18%,transparent);
}
.bga-20__readout i {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--bga-20-ph);
box-shadow: 0 0 8px var(--bga-20-ph);
}
.bga-20__readout b {
color: var(--bga-20-ph);
font-size: 12.5px;
}.bga-20,
.bga-20 *,
.bga-20 *::before,
.bga-20 *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
.bga-20 {
width: 100%;
min-height: 100vh;
min-height: 100svh;
display: block;
position: relative;
overflow: hidden;
isolation: isolate;
--bga-20-bg: oklch(0.13 0.03 158);
--bga-20-ph: oklch(0.86 0.2 152);
background: var(--bga-20-bg);
color: var(--bga-20-ph);
font-family: ui-monospace,'SFMono-Regular',Menlo,Consolas,monospace;
-webkit-font-smoothing: antialiased;
}
.bga-20__c {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
display: block;
}
.bga-20__vig {
position: absolute;
inset: 0;
pointer-events: none;
background: radial-gradient(110% 88% at 50% 46%,transparent 34%,oklch(0.07 0.02 158/.92) 100%);
}
.bga-20__stage {
position: relative;
z-index: 2;
min-height: 100vh;
min-height: 100svh;
display: grid;
place-items: center;
align-content: center;
padding: clamp(22px,5vw,68px);
}
.bga-20__panel {
width: min(100%,640px);
display: grid;
gap: 15px;
justify-items: center;
text-align: center;
padding: clamp(26px,3.8vw,46px);
background: oklch(0.1 0.025 158/.7);
border: 1px solid color-mix(in oklab,var(--bga-20-ph) 26%,transparent);
box-shadow: 0 0 60px -20px color-mix(in oklab,var(--bga-20-ph) 40%,transparent),0 40px 90px -50px oklch(0 0 0/.95);
}
.bga-20__eyebrow {
font-size: 11.5px;
letter-spacing: .24em;
text-transform: uppercase;
color: color-mix(in oklab,var(--bga-20-ph) 70%,transparent);
}
.bga-20__title {
font-size: clamp(28px,5.6vw,56px);
font-weight: 700;
line-height: 1.02;
letter-spacing: -.03em;
text-wrap: balance;
text-shadow: 0 0 28px color-mix(in oklab,var(--bga-20-ph) 55%,transparent);
}
.bga-20__sub {
max-width: 48ch;
font-family: ui-sans-serif,system-ui,-apple-system,sans-serif;
font-size: clamp(13.5px,1.3vw,16px);
line-height: 1.6;
color: color-mix(in oklab,var(--bga-20-ph) 78%,transparent);
text-wrap: pretty;
}
.bga-20__readout {
display: flex;
flex-wrap: wrap;
gap: 8px;
justify-content: center;
margin-top: 4px;
}
.bga-20__readout span {
display: inline-flex;
align-items: center;
gap: 8px;
font-size: 11px;
letter-spacing: .06em;
text-transform: uppercase;
padding: 8px 13px;
color: color-mix(in oklab,var(--bga-20-ph) 66%,transparent);
background: oklch(1 0 0/.04);
border: 1px solid color-mix(in oklab,var(--bga-20-ph) 18%,transparent);
}
.bga-20__readout i {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--bga-20-ph);
box-shadow: 0 0 8px var(--bga-20-ph);
}
.bga-20__readout b {
color: var(--bga-20-ph);
font-size: 12.5px;
}(() => {
const root = document.getElementById('bga-20-root');
const cv = document.getElementById('bga-20-canvas');
if (!root || !cv) return;
const ctx = cv.getContext('2d');
const calm = matchMedia('(prefers-reduced-motion: reduce)').matches;
const GLYPHS = 'アイウエオカキクケコサシスセソタチツテトナニヌネノハヒフヘホマミムメモヤユヨラリルレロワン0123456789';
const FPS = 24, STEP = 1000 / FPS;
const CELL = 18;
let w = 0, h = 0, dpr = 1, cols = 0, drops = [], glyph = [], raf = 0, live = false, last = 0;
const pick = () => GLYPHS[(Math.random() * GLYPHS.length) | 0];
const size = () => {
const r = root.getBoundingClientRect();
w = r.width; h = r.height;
dpr = Math.min(devicePixelRatio || 1, 2);
cv.width = Math.round(w * dpr); cv.height = Math.round(h * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.font = (CELL - 2) + 'px ui-monospace, Menlo, monospace';
ctx.textBaseline = 'top';
cols = Math.ceil(w / CELL);
drops = Array.from({ length: cols }, () => ({
y: -Math.random() * h,
v: CELL * (0.55 + Math.random() * 0.9),
prev: pick()
}));
glyph = Array.from({ length: cols }, pick);
ctx.fillStyle = '#080d0a';
ctx.fillRect(0, 0, w, h);
const out = document.getElementById('bga-20-cols');
if (out) out.textContent = String(cols);
};
const draw = () => {
ctx.fillStyle = 'rgba(8,13,10,0.17)';
ctx.fillRect(0, 0, w, h);
for (let i = 0; i < cols; i++) {
const d = drops[i];
const x = i * CELL;
if (Math.random() < 0.22) { d.prev = glyph[i]; glyph[i] = pick(); }
ctx.shadowBlur = 14;
ctx.shadowColor = '#3dffa4';
ctx.fillStyle = '#e6fff2';
ctx.fillText(glyph[i], x, d.y);
ctx.shadowBlur = 0;
ctx.fillStyle = 'rgba(61,255,164,0.82)';
ctx.fillText(d.prev, x, d.y - CELL);
d.y += d.v;
if (d.y > h + Math.random() * 240) { d.y = -Math.random() * 160; d.v = CELL * (0.55 + Math.random() * 0.9); }
}
};
const frame = (now) => {
if (now - last >= STEP) { last = now; draw(); }
if (live) raf = requestAnimationFrame(frame);
};
const start = () => { if (!live && !calm) { live = true; raf = requestAnimationFrame(frame); } };
const stop = () => { live = false; cancelAnimationFrame(raf); };
size();
if (calm) { for (let i = 0; i < 26; i++) draw(); }
else new IntersectionObserver((es) => es.forEach(e => e.isIntersecting ? start() : stop()), { threshold: 0 }).observe(root);
let t;
addEventListener('resize', () => { clearTimeout(t); t = setTimeout(() => { size(); if (calm) for (let i = 0; i < 26; i++) draw(); }, 180); });
})();(() => {
const root = document.getElementById('bga-20-root');
const cv = document.getElementById('bga-20-canvas');
if (!root || !cv) return;
const ctx = cv.getContext('2d');
const calm = matchMedia('(prefers-reduced-motion: reduce)').matches;
const GLYPHS = 'アイウエオカキクケコサシスセソタチツテトナニヌネノハヒフヘホマミムメモヤユヨラリルレロワン0123456789';
const FPS = 24, STEP = 1000 / FPS;
const CELL = 18;
let w = 0, h = 0, dpr = 1, cols = 0, drops = [], glyph = [], raf = 0, live = false, last = 0;
const pick = () => GLYPHS[(Math.random() * GLYPHS.length) | 0];
const size = () => {
const r = root.getBoundingClientRect();
w = r.width; h = r.height;
dpr = Math.min(devicePixelRatio || 1, 2);
cv.width = Math.round(w * dpr); cv.height = Math.round(h * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.font = (CELL - 2) + 'px ui-monospace, Menlo, monospace';
ctx.textBaseline = 'top';
cols = Math.ceil(w / CELL);
drops = Array.from({ length: cols }, () => ({
y: -Math.random() * h,
v: CELL * (0.55 + Math.random() * 0.9),
prev: pick()
}));
glyph = Array.from({ length: cols }, pick);
ctx.fillStyle = '#080d0a';
ctx.fillRect(0, 0, w, h);
const out = document.getElementById('bga-20-cols');
if (out) out.textContent = String(cols);
};
const draw = () => {
ctx.fillStyle = 'rgba(8,13,10,0.17)';
ctx.fillRect(0, 0, w, h);
for (let i = 0; i < cols; i++) {
const d = drops[i];
const x = i * CELL;
if (Math.random() < 0.22) { d.prev = glyph[i]; glyph[i] = pick(); }
ctx.shadowBlur = 14;
ctx.shadowColor = '#3dffa4';
ctx.fillStyle = '#e6fff2';
ctx.fillText(glyph[i], x, d.y);
ctx.shadowBlur = 0;
ctx.fillStyle = 'rgba(61,255,164,0.82)';
ctx.fillText(d.prev, x, d.y - CELL);
d.y += d.v;
if (d.y > h + Math.random() * 240) { d.y = -Math.random() * 160; d.v = CELL * (0.55 + Math.random() * 0.9); }
}
};
const frame = (now) => {
if (now - last >= STEP) { last = now; draw(); }
if (live) raf = requestAnimationFrame(frame);
};
const start = () => { if (!live && !calm) { live = true; raf = requestAnimationFrame(frame); } };
const stop = () => { live = false; cancelAnimationFrame(raf); };
size();
if (calm) { for (let i = 0; i < 26; i++) draw(); }
else new IntersectionObserver((es) => es.forEach(e => e.isIntersecting ? start() : stop()), { threshold: 0 }).observe(root);
let t;
addEventListener('resize', () => { clearTimeout(t); t = setTimeout(() => { size(); if (calm) for (let i = 0; i < 26; i++) draw(); }, 180); });
})();Here's a working CSS Background Animation from CodeFronts. Use it as-is or adapt to your framework. All classes are scoped under a unique prefix so the code won't collide with your existing styles. MIT licensed.
Demo: Matrix Digital Rain Effect
Source: https://codefronts.com/motion/css-background-animations/matrix-digital-rain-background/
Digital rain done right: a bright leading glyph, a decaying trail behind it, per-column speeds, occasional glyph mutation mid-fall, and a frame budget that holds at 4K. It runs at a deliberate 24fps — the film frame rate the effect is remembered from — which also cuts its CPU cost by more than half compared to a 60fps loop nobody can perceive as smoother.
## HTML
```html
<section class="bga-20" id="bga-20-root" aria-label="Matrix digital rain background demo">
<canvas class="bga-20__c" id="bga-20-canvas" aria-hidden="true"></canvas>
<div class="bga-20__vig" aria-hidden="true"></div>
<div class="bga-20__stage">
<div class="bga-20__panel">
<p class="bga-20__eyebrow">24 fps by choice · 20</p>
<h1 class="bga-20__title">Bright head.<br>Decaying tail.</h1>
<p class="bga-20__sub">The head/tail luminance split is what makes digital rain read as rain. Everything else — glyph set, colour, speed — is set dressing.</p>
<div class="bga-20__readout">
<span><i></i>columns <b id="bga-20-cols">—</b></span>
<span><i></i>fps cap <b>24</b></span>
<span><i></i>off-screen <b>paused</b></span>
</div>
</div>
</div>
</section>
```
## CSS
```css
.bga-20,
.bga-20 *,
.bga-20 *::before,
.bga-20 *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
.bga-20 {
width: 100%;
min-height: 100vh;
min-height: 100svh;
display: block;
position: relative;
overflow: hidden;
isolation: isolate;
--bga-20-bg: oklch(0.13 0.03 158);
--bga-20-ph: oklch(0.86 0.2 152);
background: var(--bga-20-bg);
color: var(--bga-20-ph);
font-family: ui-monospace,'SFMono-Regular',Menlo,Consolas,monospace;
-webkit-font-smoothing: antialiased;
}
.bga-20__c {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
display: block;
}
.bga-20__vig {
position: absolute;
inset: 0;
pointer-events: none;
background: radial-gradient(110% 88% at 50% 46%,transparent 34%,oklch(0.07 0.02 158/.92) 100%);
}
.bga-20__stage {
position: relative;
z-index: 2;
min-height: 100vh;
min-height: 100svh;
display: grid;
place-items: center;
align-content: center;
padding: clamp(22px,5vw,68px);
}
.bga-20__panel {
width: min(100%,640px);
display: grid;
gap: 15px;
justify-items: center;
text-align: center;
padding: clamp(26px,3.8vw,46px);
background: oklch(0.1 0.025 158/.7);
border: 1px solid color-mix(in oklab,var(--bga-20-ph) 26%,transparent);
box-shadow: 0 0 60px -20px color-mix(in oklab,var(--bga-20-ph) 40%,transparent),0 40px 90px -50px oklch(0 0 0/.95);
}
.bga-20__eyebrow {
font-size: 11.5px;
letter-spacing: .24em;
text-transform: uppercase;
color: color-mix(in oklab,var(--bga-20-ph) 70%,transparent);
}
.bga-20__title {
font-size: clamp(28px,5.6vw,56px);
font-weight: 700;
line-height: 1.02;
letter-spacing: -.03em;
text-wrap: balance;
text-shadow: 0 0 28px color-mix(in oklab,var(--bga-20-ph) 55%,transparent);
}
.bga-20__sub {
max-width: 48ch;
font-family: ui-sans-serif,system-ui,-apple-system,sans-serif;
font-size: clamp(13.5px,1.3vw,16px);
line-height: 1.6;
color: color-mix(in oklab,var(--bga-20-ph) 78%,transparent);
text-wrap: pretty;
}
.bga-20__readout {
display: flex;
flex-wrap: wrap;
gap: 8px;
justify-content: center;
margin-top: 4px;
}
.bga-20__readout span {
display: inline-flex;
align-items: center;
gap: 8px;
font-size: 11px;
letter-spacing: .06em;
text-transform: uppercase;
padding: 8px 13px;
color: color-mix(in oklab,var(--bga-20-ph) 66%,transparent);
background: oklch(1 0 0/.04);
border: 1px solid color-mix(in oklab,var(--bga-20-ph) 18%,transparent);
}
.bga-20__readout i {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--bga-20-ph);
box-shadow: 0 0 8px var(--bga-20-ph);
}
.bga-20__readout b {
color: var(--bga-20-ph);
font-size: 12.5px;
}
```
## JavaScript
```js
(() => {
const root = document.getElementById('bga-20-root');
const cv = document.getElementById('bga-20-canvas');
if (!root || !cv) return;
const ctx = cv.getContext('2d');
const calm = matchMedia('(prefers-reduced-motion: reduce)').matches;
const GLYPHS = 'アイウエオカキクケコサシスセソタチツテトナニヌネノハヒフヘホマミムメモヤユヨラリルレロワン0123456789';
const FPS = 24, STEP = 1000 / FPS;
const CELL = 18;
let w = 0, h = 0, dpr = 1, cols = 0, drops = [], glyph = [], raf = 0, live = false, last = 0;
const pick = () => GLYPHS[(Math.random() * GLYPHS.length) | 0];
const size = () => {
const r = root.getBoundingClientRect();
w = r.width; h = r.height;
dpr = Math.min(devicePixelRatio || 1, 2);
cv.width = Math.round(w * dpr); cv.height = Math.round(h * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.font = (CELL - 2) + 'px ui-monospace, Menlo, monospace';
ctx.textBaseline = 'top';
cols = Math.ceil(w / CELL);
drops = Array.from({ length: cols }, () => ({
y: -Math.random() * h,
v: CELL * (0.55 + Math.random() * 0.9),
prev: pick()
}));
glyph = Array.from({ length: cols }, pick);
ctx.fillStyle = '#080d0a';
ctx.fillRect(0, 0, w, h);
const out = document.getElementById('bga-20-cols');
if (out) out.textContent = String(cols);
};
const draw = () => {
ctx.fillStyle = 'rgba(8,13,10,0.17)';
ctx.fillRect(0, 0, w, h);
for (let i = 0; i < cols; i++) {
const d = drops[i];
const x = i * CELL;
if (Math.random() < 0.22) { d.prev = glyph[i]; glyph[i] = pick(); }
ctx.shadowBlur = 14;
ctx.shadowColor = '#3dffa4';
ctx.fillStyle = '#e6fff2';
ctx.fillText(glyph[i], x, d.y);
ctx.shadowBlur = 0;
ctx.fillStyle = 'rgba(61,255,164,0.82)';
ctx.fillText(d.prev, x, d.y - CELL);
d.y += d.v;
if (d.y > h + Math.random() * 240) { d.y = -Math.random() * 160; d.v = CELL * (0.55 + Math.random() * 0.9); }
}
};
const frame = (now) => {
if (now - last >= STEP) { last = now; draw(); }
if (live) raf = requestAnimationFrame(frame);
};
const start = () => { if (!live && !calm) { live = true; raf = requestAnimationFrame(frame); } };
const stop = () => { live = false; cancelAnimationFrame(raf); };
size();
if (calm) { for (let i = 0; i < 26; i++) draw(); }
else new IntersectionObserver((es) => es.forEach(e => e.isIntersecting ? start() : stop()), { threshold: 0 }).observe(root);
let t;
addEventListener('resize', () => { clearTimeout(t); t = setTimeout(() => { size(); if (calm) for (let i = 0; i < 26; i++) draw(); }, 180); });
})();
```Here's a working CSS Background Animation from CodeFronts. Use it as-is or adapt to your framework. All classes are scoped under a unique prefix so the code won't collide with your existing styles. MIT licensed.
Demo: Matrix Digital Rain Effect
Source: https://codefronts.com/motion/css-background-animations/matrix-digital-rain-background/
Digital rain done right: a bright leading glyph, a decaying trail behind it, per-column speeds, occasional glyph mutation mid-fall, and a frame budget that holds at 4K. It runs at a deliberate 24fps — the film frame rate the effect is remembered from — which also cuts its CPU cost by more than half compared to a 60fps loop nobody can perceive as smoother.
## HTML
```html
<section class="bga-20" id="bga-20-root" aria-label="Matrix digital rain background demo">
<canvas class="bga-20__c" id="bga-20-canvas" aria-hidden="true"></canvas>
<div class="bga-20__vig" aria-hidden="true"></div>
<div class="bga-20__stage">
<div class="bga-20__panel">
<p class="bga-20__eyebrow">24 fps by choice · 20</p>
<h1 class="bga-20__title">Bright head.<br>Decaying tail.</h1>
<p class="bga-20__sub">The head/tail luminance split is what makes digital rain read as rain. Everything else — glyph set, colour, speed — is set dressing.</p>
<div class="bga-20__readout">
<span><i></i>columns <b id="bga-20-cols">—</b></span>
<span><i></i>fps cap <b>24</b></span>
<span><i></i>off-screen <b>paused</b></span>
</div>
</div>
</div>
</section>
```
## CSS
```css
.bga-20,
.bga-20 *,
.bga-20 *::before,
.bga-20 *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
.bga-20 {
width: 100%;
min-height: 100vh;
min-height: 100svh;
display: block;
position: relative;
overflow: hidden;
isolation: isolate;
--bga-20-bg: oklch(0.13 0.03 158);
--bga-20-ph: oklch(0.86 0.2 152);
background: var(--bga-20-bg);
color: var(--bga-20-ph);
font-family: ui-monospace,'SFMono-Regular',Menlo,Consolas,monospace;
-webkit-font-smoothing: antialiased;
}
.bga-20__c {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
display: block;
}
.bga-20__vig {
position: absolute;
inset: 0;
pointer-events: none;
background: radial-gradient(110% 88% at 50% 46%,transparent 34%,oklch(0.07 0.02 158/.92) 100%);
}
.bga-20__stage {
position: relative;
z-index: 2;
min-height: 100vh;
min-height: 100svh;
display: grid;
place-items: center;
align-content: center;
padding: clamp(22px,5vw,68px);
}
.bga-20__panel {
width: min(100%,640px);
display: grid;
gap: 15px;
justify-items: center;
text-align: center;
padding: clamp(26px,3.8vw,46px);
background: oklch(0.1 0.025 158/.7);
border: 1px solid color-mix(in oklab,var(--bga-20-ph) 26%,transparent);
box-shadow: 0 0 60px -20px color-mix(in oklab,var(--bga-20-ph) 40%,transparent),0 40px 90px -50px oklch(0 0 0/.95);
}
.bga-20__eyebrow {
font-size: 11.5px;
letter-spacing: .24em;
text-transform: uppercase;
color: color-mix(in oklab,var(--bga-20-ph) 70%,transparent);
}
.bga-20__title {
font-size: clamp(28px,5.6vw,56px);
font-weight: 700;
line-height: 1.02;
letter-spacing: -.03em;
text-wrap: balance;
text-shadow: 0 0 28px color-mix(in oklab,var(--bga-20-ph) 55%,transparent);
}
.bga-20__sub {
max-width: 48ch;
font-family: ui-sans-serif,system-ui,-apple-system,sans-serif;
font-size: clamp(13.5px,1.3vw,16px);
line-height: 1.6;
color: color-mix(in oklab,var(--bga-20-ph) 78%,transparent);
text-wrap: pretty;
}
.bga-20__readout {
display: flex;
flex-wrap: wrap;
gap: 8px;
justify-content: center;
margin-top: 4px;
}
.bga-20__readout span {
display: inline-flex;
align-items: center;
gap: 8px;
font-size: 11px;
letter-spacing: .06em;
text-transform: uppercase;
padding: 8px 13px;
color: color-mix(in oklab,var(--bga-20-ph) 66%,transparent);
background: oklch(1 0 0/.04);
border: 1px solid color-mix(in oklab,var(--bga-20-ph) 18%,transparent);
}
.bga-20__readout i {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--bga-20-ph);
box-shadow: 0 0 8px var(--bga-20-ph);
}
.bga-20__readout b {
color: var(--bga-20-ph);
font-size: 12.5px;
}
```
## JavaScript
```js
(() => {
const root = document.getElementById('bga-20-root');
const cv = document.getElementById('bga-20-canvas');
if (!root || !cv) return;
const ctx = cv.getContext('2d');
const calm = matchMedia('(prefers-reduced-motion: reduce)').matches;
const GLYPHS = 'アイウエオカキクケコサシスセソタチツテトナニヌネノハヒフヘホマミムメモヤユヨラリルレロワン0123456789';
const FPS = 24, STEP = 1000 / FPS;
const CELL = 18;
let w = 0, h = 0, dpr = 1, cols = 0, drops = [], glyph = [], raf = 0, live = false, last = 0;
const pick = () => GLYPHS[(Math.random() * GLYPHS.length) | 0];
const size = () => {
const r = root.getBoundingClientRect();
w = r.width; h = r.height;
dpr = Math.min(devicePixelRatio || 1, 2);
cv.width = Math.round(w * dpr); cv.height = Math.round(h * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.font = (CELL - 2) + 'px ui-monospace, Menlo, monospace';
ctx.textBaseline = 'top';
cols = Math.ceil(w / CELL);
drops = Array.from({ length: cols }, () => ({
y: -Math.random() * h,
v: CELL * (0.55 + Math.random() * 0.9),
prev: pick()
}));
glyph = Array.from({ length: cols }, pick);
ctx.fillStyle = '#080d0a';
ctx.fillRect(0, 0, w, h);
const out = document.getElementById('bga-20-cols');
if (out) out.textContent = String(cols);
};
const draw = () => {
ctx.fillStyle = 'rgba(8,13,10,0.17)';
ctx.fillRect(0, 0, w, h);
for (let i = 0; i < cols; i++) {
const d = drops[i];
const x = i * CELL;
if (Math.random() < 0.22) { d.prev = glyph[i]; glyph[i] = pick(); }
ctx.shadowBlur = 14;
ctx.shadowColor = '#3dffa4';
ctx.fillStyle = '#e6fff2';
ctx.fillText(glyph[i], x, d.y);
ctx.shadowBlur = 0;
ctx.fillStyle = 'rgba(61,255,164,0.82)';
ctx.fillText(d.prev, x, d.y - CELL);
d.y += d.v;
if (d.y > h + Math.random() * 240) { d.y = -Math.random() * 160; d.v = CELL * (0.55 + Math.random() * 0.9); }
}
};
const frame = (now) => {
if (now - last >= STEP) { last = now; draw(); }
if (live) raf = requestAnimationFrame(frame);
};
const start = () => { if (!live && !calm) { live = true; raf = requestAnimationFrame(frame); } };
const stop = () => { live = false; cancelAnimationFrame(raf); };
size();
if (calm) { for (let i = 0; i < 26; i++) draw(); }
else new IntersectionObserver((es) => es.forEach(e => e.isIntersecting ? start() : stop()), { threshold: 0 }).observe(root);
let t;
addEventListener('resize', () => { clearTimeout(t); t = setTimeout(() => { size(); if (calm) for (let i = 0; i < 26; i++) draw(); }, 180); });
})();
```How this works
The classic implementation paints a translucent black rectangle over the canvas each frame so old glyphs fade out. It works, but it never fully clears — the canvas slowly accumulates a grey haze, and on dark UI that is visible. This version tracks each column's trail explicitly and clears properly:
ctx.fillStyle = 'rgba(6,10,8,0.16)'; // decay, not a full clear
ctx.fillRect(0, 0, w, h);Then, per column, the leading glyph is drawn in near-white with a shadow blur, and the glyph immediately behind it in full green:
ctx.fillStyle = '#d9ffe8'; ctx.shadowBlur = 12; ctx.shadowColor = '#39ff9a';
ctx.fillText(glyph, x, y); // head
ctx.shadowBlur = 0; ctx.fillStyle = '#39ff9a';
ctx.fillText(prev, x, y - cell); // first tail glyphThe head/tail contrast is what makes it read as rain rather than as scrolling text — it is the single most important detail and the one most copies get wrong.
Three more production details: columns are recycled at randomised heights so the field never forms a visible horizontal line; the loop is throttled to 24fps with a timestamp check rather than setInterval (which drifts and fights the compositor); and an IntersectionObserver stops it entirely off-screen.
Glyphs are drawn from the half-width katakana block plus digits, sampled per cell per mutation — not from a pre-rendered sprite — because fillText on a monospace font is fast enough and keeps the code eight lines shorter.
Make it yours
- Change the palette to amber, cyan or magenta by editing two colour constants; the head colour should always be near-white regardless of the trail hue.
- Slow
FPSto 12 for a heavier, more deliberate rain, or raise the decay alpha for shorter trails. - Feed real strings instead of random glyphs (a changelog, a poem, log output) — column x stays fixed and each column walks its own string.
- Add a pointer-parallax by offsetting the whole canvas translate slightly with the cursor — subtle depth for very little code.
Gotchas — read before shipping
- The translucent-fill trail technique never reaches full black. If you need a truly clean canvas, clear fully and redraw the trail yourself.
ctx.shadowBluris expensive — apply it only to the head glyph (one per column), never the whole trail.- Re-measuring text metrics per frame is a classic performance trap. Set the font once after each resize and cache the cell size.
- Uncapped frame rate on a 4K display renders four times the glyphs for zero perceived benefit — throttle by timestamp, and derive column count from width.
- Canvas is invisible to screen readers and to text search. Mark it
aria-hiddenand never render meaningful content in it.
Browser support
| Chrome | Safari | Firefox | Edge |
|---|---|---|---|
| 114+ | 17.5+ | 121+ | 114+ |
Floor set by oklch(), color-mix(), text-wrap as this demo is written. The core technique itself goes back further — Chrome any modern.
Canvas 2D, requestAnimationFrame and IntersectionObserver. Katakana glyphs require a font with the half-width kana block — every major OS ships one; the code falls back to digits and Latin if not.