03Window Droplets Trickling Down and Merging Into Each Other
Each droplet's weight and drag are recalculated to set its slide speed, and when its path overlaps another droplet the two merge into one larger drop.
Best forMood intro-section background
dropletMassMergeDROPLET_GRAVITY_PX_SDROPLET_MERGE_DIST_PX

<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">Each droplet has its weight and drag calculated to reset its trickle speed, and when its path overlaps another droplet the two merge into one, growing larger.</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-orange);
.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-orange;
}
.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, 255, 255, .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: #ff4d1f;
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: #ff4d1f;
}
.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, 255, 255, 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 () {
// 기법: 물방울마다 질량(mass)을 갖고, 매 프레임 무게 대 저항(DROPLET_DRAG_COEF) 비율로
// 목표 속도를 다시 계산해 그쪽으로 서서히 붙는다(lerp). dropletMassMerge() 가 겹친 두
// 물방울의 질량을 합쳐 반지름을 키우고 하나로 줄인다 — 무거워질수록 더 빨리 흘러내린다.
// 하이라이트는 물방울 왼쪽 위에 밝은 점을 얹어 굴절된 빛처럼 보이게 한다.
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 DROPLET_DRAG_COEF = 3.2;
var DROPLET_GRAVITY_PX_S = 58;
var DROPLET_MERGE_DIST_PX = 2.4;
var DROPLET_SPAWN_PER_S = 2.6;
function seedRand(seed) {
var s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
var dRand = seedRand(431031);
var drops = [];
var spawnAcc = 0;
function spawnDroplet() {
var r = 1.6 + dRand() * 1.6;
drops.push({
x: dRand() * CALC_W,
y: -3,
r: r,
mass: r * r,
vy: 4,
seed: dRand() * 1000
});
}
function dropletMassMerge(a, b) {
var mass = a.mass + b.mass;
var r = Math.sqrt(mass) * 0.92;
return {
x: (a.x * a.mass + b.x * b.mass) / mass,
y: Math.max(a.y, b.y),
r: r, mass: mass, vy: Math.max(a.vy, b.vy), seed: a.seed
};
}
function stepSim(dtMs, nowMs) {
var dtS = dtMs / 1000;
spawnAcc += dtMs * DROPLET_SPAWN_PER_S / 1000;
while (spawnAcc >= 1 && drops.length < 46) { spawnDroplet(); spawnAcc -= 1; }
for (var i = 0; i < drops.length; i += 1) {
var d = drops[i];
var targetVy = (DROPLET_GRAVITY_PX_S * Math.sqrt(d.mass)) / DROPLET_DRAG_COEF;
d.vy += (targetVy - d.vy) * Math.min(1, 5 * dtS);
d.y += d.vy * dtS;
d.x += Math.sin(nowMs * 0.0012 + d.seed) * 1.6 * dtS;
}
// 병합 — 가까운 두 물방울이 겹치면 하나로
for (var a = 0; a < drops.length; a += 1) {
for (var b = a + 1; b < drops.length; b += 1) {
var p1 = drops[a], p2 = drops[b];
if (!p1 || !p2) continue;
var dx = p1.x - p2.x, dy = p1.y - p2.y;
var dist = Math.sqrt(dx * dx + dy * dy);
if (dist < DROPLET_MERGE_DIST_PX + (p1.r + p2.r) * 0.55) {
drops[a] = dropletMassMerge(p1, p2);
drops.splice(b, 1);
b -= 1;
}
}
}
var alive = [];
for (var k = 0; k < drops.length; k += 1) {
if (drops[k].y - drops[k].r < CALC_H + 6) alive.push(drops[k]);
}
drops = alive;
}
function draw() {
var sx = cw / CALC_W, sy = ch / CALC_H;
var s = (sx + sy) / 2;
ctx.clearRect(0, 0, cw, ch);
for (var i = 0; i < drops.length; i += 1) {
var d = drops[i];
var dx = d.x * sx, dy = d.y * sy, dr = d.r * s;
ctx.fillStyle = 'rgba(15,24,52,.42)';
ctx.beginPath();
ctx.arc(dx, dy, dr, 0, Math.PI * 2);
ctx.fill();
var g = ctx.createRadialGradient(dx - dr * 0.35, dy - dr * 0.4, 0, dx - dr * 0.35, dy - dr * 0.4, dr * 0.7);
g.addColorStop(0, 'rgba(255,255,255,.85)');
g.addColorStop(1, 'rgba(255,255,255,0)');
ctx.fillStyle = g;
ctx.beginPath();
ctx.arc(dx - dr * 0.35, dy - dr * 0.4, dr * 0.55, 0, Math.PI * 2);
ctx.fill();
ctx.strokeStyle = 'rgba(255,255,255,1)';
ctx.beginPath();
ctx.moveTo(dx - dr * 0.1, dy - dr * 1.3);
ctx.lineTo(dx - dr * 0.1, dy - dr * (1.3 - Math.min(0.7, dr * 0.05)));
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();
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var elapsed = 0;
if (reduce) root.classList.remove('is-demo');
var warmSteps = reduce ? 180 : 90;
for (var w = 0; w < warmSteps; w += 1) { elapsed += 20; stepSim(20, 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, elapsed);
draw();
}
requestAnimationFrame(tick);
}
fit();
window.addEventListener('resize', fit);
draw();
requestAnimationFrame(tick);
})();import { useEffect, useRef } from "react";
import styles from "./RainEffectCanvasWindowDropletTrickle.module.scss";
type Droplet = { x: number; y: number; r: number; mass: number; vy: number; seed: number };
export default function RainEffectCanvasWindowDropletTrickle() {
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 DROPLET_DRAG_COEF = 3.2;
const DROPLET_GRAVITY_PX_S = 58;
const DROPLET_MERGE_DIST_PX = 2.4;
const DROPLET_SPAWN_PER_S = 2.6;
function seedRand(seed: number) {
let s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
const dRand = seedRand(431032);
let drops: Droplet[] = [];
let spawnAcc = 0;
function spawnDroplet() {
const r = 1.6 + dRand() * 1.6;
drops.push({ x: dRand() * CALC_W, y: -3, r, mass: r * r, vy: 4, seed: dRand() * 1000 });
}
function dropletMassMerge(a: Droplet, b: Droplet): Droplet {
const mass = a.mass + b.mass;
const r = Math.sqrt(mass) * 0.92;
return {
x: (a.x * a.mass + b.x * b.mass) / mass,
y: Math.max(a.y, b.y),
r, mass, vy: Math.max(a.vy, b.vy), seed: a.seed
};
}
function stepSim(dtMs: number, nowMs: number) {
const dtS = dtMs / 1000;
spawnAcc += (dtMs * DROPLET_SPAWN_PER_S) / 1000;
while (spawnAcc >= 1 && drops.length < 46) { spawnDroplet(); spawnAcc -= 1; }
for (let i = 0; i < drops.length; i += 1) {
const d = drops[i];
const targetVy = (DROPLET_GRAVITY_PX_S * Math.sqrt(d.mass)) / DROPLET_DRAG_COEF;
d.vy += (targetVy - d.vy) * Math.min(1, 5 * dtS);
d.y += d.vy * dtS;
d.x += Math.sin(nowMs * 0.0012 + d.seed) * 1.6 * dtS;
}
for (let a = 0; a < drops.length; a += 1) {
for (let b = a + 1; b < drops.length; b += 1) {
const p1 = drops[a], p2 = drops[b];
if (!p1 || !p2) continue;
const dx = p1.x - p2.x, dy = p1.y - p2.y;
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist < DROPLET_MERGE_DIST_PX + (p1.r + p2.r) * 0.55) {
drops[a] = dropletMassMerge(p1, p2);
drops.splice(b, 1);
b -= 1;
}
}
}
drops = drops.filter((d) => d.y - d.r < CALC_H + 6);
}
let cw = 480, ch = 300;
function draw() {
const sx = cw / CALC_W, sy = ch / CALC_H;
const s = (sx + sy) / 2;
ctx!.clearRect(0, 0, cw, ch);
for (let i = 0; i < drops.length; i += 1) {
const d = drops[i];
const dx = d.x * sx, dy = d.y * sy, dr = d.r * s;
ctx!.fillStyle = "rgba(15,24,52,.42)";
ctx!.beginPath();
ctx!.arc(dx, dy, dr, 0, Math.PI * 2);
ctx!.fill();
const g = ctx!.createRadialGradient(dx - dr * 0.35, dy - dr * 0.4, 0, dx - dr * 0.35, dy - dr * 0.4, dr * 0.7);
g.addColorStop(0, "rgba(255,255,255,.85)");
g.addColorStop(1, "rgba(255,255,255,0)");
ctx!.fillStyle = g;
ctx!.beginPath();
ctx!.arc(dx - dr * 0.35, dy - dr * 0.4, dr * 0.55, 0, Math.PI * 2);
ctx!.fill();
}
}
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();
}
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let elapsed = 0;
if (reduce) root.classList.remove("is-demo");
const warmSteps = reduce ? 180 : 90;
for (let w = 0; w < warmSteps; w += 1) { elapsed += 20; stepSim(20, 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, elapsed);
draw();
}
raf = requestAnimationFrame(tick);
}
fit();
window.addEventListener("resize", fit);
draw();
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>
);
}03 Window Droplets Trickling Down and Merging Into Each Other — Measured area 8.967% · 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
Galleries04 Smoke That Fades and Dissolves the Farther It Drifts From Its SourceCSS Smoke Effect: 9 Canvas Noise Patterns
Galleries09 Bricks That Fall, Bounce and Stack Into PlaceCSS Masonry Layout: 9 Ways to Pack Uneven Cards
Galleries06 An Image Watercolor Filter That Turns a Landscape into Paint with Stepped Brightness and Wavering EdgesWatercolor Effect CSS: 9 Bleeding Paint Scenes