05Scene Cycling in Intensity from Drizzle to Downpour
One slow periodic function keeps the storm's intensity rising and falling, and the streak count and wind strength are recalculated from that same value every frame so the whole scene weakens and strengthens together.
Best forLong-running hero background that never feels static
stormIntensitySTORM_CYCLE_MSRAIN_SPAWN_RATE_BASE

<main class="stage">
<div class="rn is-demo" id="rnRoot">
<canvas class="rn__canvas" id="rnCanvas" width="480" height="300"></canvas>
<p class="rn__caption">One slow cycle function keeps the storm's intensity rising and falling, and streak count and wind strength are recalculated every frame to match — the whole scene weakens and strengthens together.</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.rn {
position: relative;
width: min(92vw, calc(160vh - 12.8vw));
aspect-ratio: 480 / 300;
margin: 0 auto;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
background: $stage-ink;
}
.rn__canvas {
display: block;
width: 100%;
height: 100%;
}
.rn__caption {
position: absolute;
left: $sp-3;
bottom: $sp-3;
max-width: 78%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 247, 230, .85);
color: $subject-ink;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.rn__caption { font-size: 10px; padding: $sp-1 $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.rn { cursor: default; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.rn {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: #17141a;
}
.rn__canvas {
display: block;
width: 100%;
height: 100%;
}
.rn__caption {
position: absolute;
left: 12px;
bottom: 12px;
max-width: 78%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 247, 230, 0.85);
color: #17141a;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.rn__caption {
font-size: 10px;
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.rn {
cursor: default;
}
}(function () {
// 기법: stormIntensity() 하나가 사인 주기(STORM_CYCLE_MS)로 0~1 을 오르내리고, 그 값 하나로
// 화면에 보이는 빗줄기 수·굵기·기울기·어두운 정도를 매 프레임 다시 계산한다. RAIN_SPAWN_RATE_BASE
// 는 가장 약한 이슬비일 때도 유지하는 최소 줄기 수 — 그 위로 세기만큼 더 얹는다.
var root = document.getElementById('rnRoot');
var canvas = document.getElementById('rnCanvas');
var ctx = canvas.getContext('2d');
var wrap = canvas.parentElement;
var CALC_W = 160, CALC_H = 100;
var STORM_CYCLE_MS = 9000;
var RAIN_SPAWN_RATE_BASE = 18;
var RAIN_POOL_SIZE = 110;
function seedRand(seed) {
var s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
var rRand = seedRand(431051);
function stormIntensity(nowMs) {
return Math.sin((nowMs / STORM_CYCLE_MS) * Math.PI * 2 - Math.PI / 2) * 0.5 + 0.5;
}
var streaks = [];
for (var i = 0; i < RAIN_POOL_SIZE; i += 1) {
streaks.push({
x: rRand() * CALC_W,
y: rRand() * CALC_H,
thresh: rRand(),
speed: 90 + rRand() * 90,
thick: 0.7 + rRand() * 1.3
});
}
function stepSim(dtS, nowMs) {
var inten = stormIntensity(nowMs);
var wind = (inten - 0.5) * 20;
for (var i = 0; i < streaks.length; i += 1) {
var s = streaks[i];
var visible = inten >= s.thresh * (1 - RAIN_SPAWN_RATE_BASE / 100);
if (!visible) continue;
s.y += s.speed * (0.6 + inten * 0.7) * dtS;
s.x += wind * dtS;
if (s.y > CALC_H + 6) { s.y = -6 - rRand() * 8; s.x = rRand() * CALC_W; }
if (s.x < -6) s.x = CALC_W + 6;
if (s.x > CALC_W + 6) s.x = -6;
}
}
function draw(nowMs) {
var sx = cw / CALC_W, sy = ch / CALC_H;
var inten = stormIntensity(nowMs);
ctx.clearRect(0, 0, cw, ch);
ctx.fillStyle = 'rgba(6,8,20,' + (inten * 0.4).toFixed(3) + ')';
ctx.fillRect(0, 0, cw, ch);
for (var i = 0; i < streaks.length; i += 1) {
var s = streaks[i];
var visible = inten >= s.thresh * (1 - RAIN_SPAWN_RATE_BASE / 100);
if (!visible) continue;
var alpha = 0.35 + inten * 0.55;
var x = s.x * sx, y0 = s.y * sy, y1 = (s.y - s.thick * 5) * sy;
ctx.strokeStyle = 'rgba(214,228,255,' + alpha + ')';
ctx.lineWidth = Math.max(0.6, s.thick * ((sx + sy) / 2));
ctx.beginPath();
ctx.moveTo(x, y0);
ctx.lineTo(x, y1);
ctx.stroke();
}
}
var cw = 480, ch = 300;
function fit() {
var r = wrap.getBoundingClientRect();
cw = Math.max(1, Math.round(r.width));
ch = Math.max(1, Math.round(r.height));
canvas.width = cw; canvas.height = ch;
draw(elapsed);
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var elapsed = 2200;
if (reduce) root.classList.remove('is-demo');
var warmSteps = reduce ? 0 : 40;
for (var w = 0; w < warmSteps; w += 1) { elapsed += 20; stepSim(0.02, elapsed); }
var lastTs = null;
function tick(ts) {
if (lastTs === null) lastTs = ts;
var dt = Math.min(ts - lastTs, 48);
lastTs = ts;
if (!reduce) {
elapsed += dt;
stepSim(dt / 1000, elapsed);
draw(elapsed);
}
requestAnimationFrame(tick);
}
fit();
window.addEventListener('resize', fit);
draw(elapsed);
requestAnimationFrame(tick);
})();import { useEffect, useRef } from "react";
import styles from "./RainEffectCanvasStormIntensityCycle.module.scss";
type Streak = { x: number; y: number; thresh: number; speed: number; thick: number };
export default function RainEffectCanvasStormIntensityCycle() {
const rootRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const root = rootRef.current;
const canvas = canvasRef.current;
if (!root || !canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const wrap = canvas.parentElement as HTMLElement;
const CALC_W = 160, CALC_H = 100;
const STORM_CYCLE_MS = 9000;
const RAIN_SPAWN_RATE_BASE = 18;
const RAIN_POOL_SIZE = 110;
function seedRand(seed: number) {
let s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
const rRand = seedRand(431052);
function stormIntensity(nowMs: number) {
return Math.sin((nowMs / STORM_CYCLE_MS) * Math.PI * 2 - Math.PI / 2) * 0.5 + 0.5;
}
const streaks: Streak[] = [];
for (let i = 0; i < RAIN_POOL_SIZE; i += 1) {
streaks.push({ x: rRand() * CALC_W, y: rRand() * CALC_H, thresh: rRand(), speed: 90 + rRand() * 90, thick: 0.7 + rRand() * 1.3 });
}
function stepSim(dtS: number, nowMs: number) {
const inten = stormIntensity(nowMs);
const wind = (inten - 0.5) * 20;
for (let i = 0; i < streaks.length; i += 1) {
const s = streaks[i];
const visible = inten >= s.thresh * (1 - RAIN_SPAWN_RATE_BASE / 100);
if (!visible) continue;
s.y += s.speed * (0.6 + inten * 0.7) * dtS;
s.x += wind * dtS;
if (s.y > CALC_H + 6) { s.y = -6 - rRand() * 8; s.x = rRand() * CALC_W; }
if (s.x < -6) s.x = CALC_W + 6;
if (s.x > CALC_W + 6) s.x = -6;
}
}
let cw = 480, ch = 300;
function draw(nowMs: number) {
const sx = cw / CALC_W, sy = ch / CALC_H;
const inten = stormIntensity(nowMs);
ctx!.clearRect(0, 0, cw, ch);
ctx!.fillStyle = "rgba(6,8,20," + (inten * 0.4).toFixed(3) + ")";
ctx!.fillRect(0, 0, cw, ch);
for (let i = 0; i < streaks.length; i += 1) {
const s = streaks[i];
const visible = inten >= s.thresh * (1 - RAIN_SPAWN_RATE_BASE / 100);
if (!visible) continue;
const alpha = 0.35 + inten * 0.55;
const x = s.x * sx, y0 = s.y * sy, y1 = (s.y - s.thick * 5) * sy;
ctx!.strokeStyle = "rgba(214,228,255," + alpha + ")";
ctx!.lineWidth = Math.max(0.6, s.thick * ((sx + sy) / 2));
ctx!.beginPath();
ctx!.moveTo(x, y0);
ctx!.lineTo(x, y1);
ctx!.stroke();
}
}
function fit() {
const r = wrap.getBoundingClientRect();
cw = Math.max(1, Math.round(r.width));
ch = Math.max(1, Math.round(r.height));
canvas!.width = cw; canvas!.height = ch;
draw(elapsed);
}
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let elapsed = 2200;
if (reduce) root.classList.remove("is-demo");
const warmSteps = reduce ? 0 : 40;
for (let w = 0; w < warmSteps; w += 1) { elapsed += 20; stepSim(0.02, elapsed); }
let lastTs: number | null = null;
let raf = 0;
function tick(ts: number) {
if (lastTs === null) lastTs = ts;
const dt = Math.min(ts - lastTs, 48);
lastTs = ts;
if (!reduce) {
elapsed += dt;
stepSim(dt / 1000, elapsed);
draw(elapsed);
}
raf = requestAnimationFrame(tick);
}
fit();
window.addEventListener("resize", fit);
draw(elapsed);
raf = requestAnimationFrame(tick);
return () => {
cancelAnimationFrame(raf);
window.removeEventListener("resize", fit);
};
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.rn} is-demo`} ref={rootRef}>
<canvas className={styles.rn__canvas} ref={canvasRef} width={480} height={300} />
<p className={styles.rn__caption}>느린 주기 함수 하나로 폭풍의 세기를 계속 오르내리게 하고, 그 값에 맞춰 빗줄기 개수와 바람 세기를 매 프레임 다시 계산해 장면 전체가 함께 약해지고 세진다</p>
</div>
</main>
);
}05 Scene Cycling in Intensity from Drizzle to Downpour — Measured area 60.446% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · 9 CSS Rain Animation Layers for a Storm Window
The post this demo lives in
9 CSS Rain Animation Layers for a Storm WindowThis css rain animation set turns a storm window into nine canvas layers. One wind-vector value in this background scene See all demos in this post
If you like this effect - similar demos
Galleries02 Ambient Auto-Ripple BackgroundImage Ripple Effect CSS: 9 Canvas Wave Patterns
Galleries04 Smoke That Fades and Dissolves the Farther It Drifts From Its SourceCSS Smoke Effect: 9 Canvas Noise Patterns
Galleries08 Lightbox slider opening from an album gridCSS Image Slider — 9 Responsive Sliders, No GSAP