07Background Wind Leaning Toward the Pointer Position
The wind vector's direction is recalculated by how far the pointer sits from the center, so every streak leans and falls in that same direction while the pointer moves.
Best forInteractive background for a product-showcase section
windLeanTargetPOINTER_WIND_LERPvirtualPointerSweep

<main class="stage">
<div class="rn is-demo" id="rnRoot" tabindex="0" role="img" aria-label="A scene where moving the pointer left or right, or pressing the arrow keys, lays the rain over toward that side">
<canvas class="rn__canvas" id="rnCanvas" width="480" height="300"></canvas>
<p class="rn__caption">The wind vector's direction is recalculated by how far the pointer sits from the center, so every streak leans and falls in that same direction while the pointer moves.</p>
<p class="rn__hint">Move the pointer or press ← →</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: #241812;
cursor: pointer;
&:focus-visible {
outline: 3px solid $subject-orange-deep;
outline-offset: 2px;
}
}
.rn__canvas {
display: block;
width: 100%;
height: 100%;
}
.rn__caption {
position: absolute;
left: $sp-3;
bottom: $sp-3;
max-width: 72%;
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;
}
.rn__hint {
position: absolute;
right: $sp-3;
top: $sp-3;
margin: 0;
padding: $sp-1 $sp-2;
border-radius: $r-pill;
background: rgba(23, 20, 26, .55);
color: $subject-cream;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.rn__caption { font-size: 10px; padding: $sp-1 $sp-2; }
.rn__hint { display: none; }
}
@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: #241812;
cursor: pointer;
}
.rn:focus-visible {
outline: 3px solid #c93000;
outline-offset: 2px;
}
.rn__canvas {
display: block;
width: 100%;
height: 100%;
}
.rn__caption {
position: absolute;
left: 12px;
bottom: 12px;
max-width: 72%;
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;
}
.rn__hint {
position: absolute;
right: 12px;
top: 12px;
margin: 0;
padding: 4px 8px;
border-radius: 999px;
background: rgba(23, 20, 26, 0.55);
color: #fff7e6;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.rn__caption {
font-size: 10px;
padding: 4px 8px;
}
.rn__hint {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.rn {
cursor: default;
}
}(function () {
// 기법: windLeanTarget() 이 포인터(또는 가상 포인터) x 위치를 -1~1 로 정규화해 기울기 목표각을 정하고,
// 실제 기울기는 POINTER_WIND_LERP 비율로 목표각을 매 프레임 뒤쫓는다(즉시 꺾이지 않고 관성처럼 따라감).
// virtualPointerSweep() 은 실제 입력이 없을 때 같은 함수를 그대로 호출하는 자동 시연 경로.
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 POINTER_WIND_LERP = 0.06;
var WIND_LEAN_MAX_DEG = 22;
var RAIN_STREAK_COUNT = 90;
var SWEEP_PERIOD_MS = 3400;
function seedRand(seed) {
var s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
var rRand = seedRand(431071);
var streaks = [];
for (var i = 0; i < RAIN_STREAK_COUNT; i += 1) {
streaks.push({ x: rRand() * CALC_W, y: rRand() * CALC_H, speed: 100 + rRand() * 80, thick: 0.6 + rRand() * 1.1 });
}
var manual = false;
var pointerNormX = 0;
var manualReleaseTimer = null;
function windLeanTarget(normX) {
return normX * WIND_LEAN_MAX_DEG;
}
function virtualPointerSweep(nowMs) {
return Math.sin((nowMs / SWEEP_PERIOD_MS) * Math.PI * 2);
}
var leanDeg = 0;
function stepSim(dtS, nowMs) {
var normX = manual ? pointerNormX : virtualPointerSweep(nowMs);
var target = windLeanTarget(normX);
leanDeg += (target - leanDeg) * Math.min(1, POINTER_WIND_LERP * (dtS * 60));
var leanRad = (leanDeg * Math.PI) / 180;
for (var i = 0; i < streaks.length; i += 1) {
var s = streaks[i];
s.y += s.speed * dtS;
s.x += Math.sin(leanRad) * s.speed * 0.7 * dtS;
if (s.y > CALC_H + 4) { s.y = -4 - rRand() * 6; s.x = rRand() * CALC_W; }
if (s.x < -6) s.x = CALC_W + 6;
if (s.x > CALC_W + 6) s.x = -6;
}
}
var cw = 480, ch = 300;
function draw() {
var sx = cw / CALC_W, sy = ch / CALC_H;
ctx.clearRect(0, 0, cw, ch);
ctx.fillStyle = 'rgba(38,20,10,0.06)';
ctx.fillRect(0, 0, cw, ch);
var leanRad = (leanDeg * Math.PI) / 180;
var dx = Math.sin(leanRad) * 6, dy = Math.cos(leanRad) * 6;
ctx.strokeStyle = 'rgba(255,236,214,0.55)';
for (var i = 0; i < streaks.length; i += 1) {
var s = streaks[i];
var x = s.x * sx, y = s.y * sy;
ctx.lineWidth = Math.max(0.6, s.thick * ((sx + sy) / 2));
ctx.beginPath();
ctx.moveTo(x, y);
ctx.lineTo(x - dx * s.thick * (sx / 6), y - dy * s.thick * (sy / 6));
ctx.stroke();
}
}
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();
}
function setManualFromClientX(clientX) {
var r = canvas.getBoundingClientRect();
var normX = ((clientX - r.left) / Math.max(1, r.width)) * 2 - 1;
pointerNormX = Math.max(-1, Math.min(1, normX));
manual = true;
root.classList.remove('is-demo');
if (manualReleaseTimer) clearTimeout(manualReleaseTimer);
}
function scheduleRelease() {
if (manualReleaseTimer) clearTimeout(manualReleaseTimer);
manualReleaseTimer = setTimeout(function () { manual = false; }, 2600);
}
canvas.addEventListener('pointermove', function (e) {
setManualFromClientX(e.clientX);
scheduleRelease();
});
canvas.addEventListener('pointerdown', function (e) {
setManualFromClientX(e.clientX);
scheduleRelease();
});
canvas.addEventListener('pointerleave', function () {
scheduleRelease();
});
root.addEventListener('keydown', function (e) {
if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') {
e.preventDefault();
manual = true;
root.classList.remove('is-demo');
var step = e.key === 'ArrowLeft' ? -0.18 : 0.18;
pointerNormX = Math.max(-1, Math.min(1, pointerNormX + step));
scheduleRelease();
}
});
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var elapsed = 0;
if (reduce) root.classList.remove('is-demo');
var warmSteps = reduce ? 30 : 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();
}
requestAnimationFrame(tick);
}
fit();
window.addEventListener('resize', fit);
draw();
requestAnimationFrame(tick);
})();import { useEffect, useRef } from "react";
import styles from "./RainEffectCanvasPointerWindLean.module.scss";
type Streak = { x: number; y: number; speed: number; thick: number };
export default function RainEffectCanvasPointerWindLean() {
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 POINTER_WIND_LERP = 0.06;
const WIND_LEAN_MAX_DEG = 22;
const RAIN_STREAK_COUNT = 90;
const SWEEP_PERIOD_MS = 3400;
function seedRand(seed: number) {
let s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
const rRand = seedRand(431072);
const streaks: Streak[] = [];
for (let i = 0; i < RAIN_STREAK_COUNT; i += 1) {
streaks.push({ x: rRand() * CALC_W, y: rRand() * CALC_H, speed: 100 + rRand() * 80, thick: 0.6 + rRand() * 1.1 });
}
let manual = false;
let pointerNormX = 0;
let manualReleaseTimer: number | undefined;
function windLeanTarget(normX: number) {
return normX * WIND_LEAN_MAX_DEG;
}
function virtualPointerSweep(nowMs: number) {
return Math.sin((nowMs / SWEEP_PERIOD_MS) * Math.PI * 2);
}
let leanDeg = 0;
function stepSim(dtS: number, nowMs: number) {
const normX = manual ? pointerNormX : virtualPointerSweep(nowMs);
const target = windLeanTarget(normX);
leanDeg += (target - leanDeg) * Math.min(1, POINTER_WIND_LERP * (dtS * 60));
const leanRad = (leanDeg * Math.PI) / 180;
for (let i = 0; i < streaks.length; i += 1) {
const s = streaks[i];
s.y += s.speed * dtS;
s.x += Math.sin(leanRad) * s.speed * 0.7 * dtS;
if (s.y > CALC_H + 4) { s.y = -4 - rRand() * 6; 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() {
const sx = cw / CALC_W, sy = ch / CALC_H;
ctx!.clearRect(0, 0, cw, ch);
ctx!.fillStyle = "rgba(38,20,10,0.06)";
ctx!.fillRect(0, 0, cw, ch);
const leanRad = (leanDeg * Math.PI) / 180;
const ddx = Math.sin(leanRad) * 6, ddy = Math.cos(leanRad) * 6;
ctx!.strokeStyle = "rgba(255,236,214,0.55)";
for (let i = 0; i < streaks.length; i += 1) {
const s = streaks[i];
const x = s.x * sx, y = s.y * sy;
ctx!.lineWidth = Math.max(0.6, s.thick * ((sx + sy) / 2));
ctx!.beginPath();
ctx!.moveTo(x, y);
ctx!.lineTo(x - ddx * s.thick * (sx / 6), y - ddy * s.thick * (sy / 6));
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();
}
function setManualFromClientX(clientX: number) {
const r = canvas!.getBoundingClientRect();
const normX = ((clientX - r.left) / Math.max(1, r.width)) * 2 - 1;
pointerNormX = Math.max(-1, Math.min(1, normX));
manual = true;
root!.classList.remove("is-demo");
if (manualReleaseTimer) window.clearTimeout(manualReleaseTimer);
}
function scheduleRelease() {
if (manualReleaseTimer) window.clearTimeout(manualReleaseTimer);
manualReleaseTimer = window.setTimeout(function () { manual = false; }, 2600);
}
function onPointerMove(e: PointerEvent) { setManualFromClientX(e.clientX); scheduleRelease(); }
function onPointerDown(e: PointerEvent) { setManualFromClientX(e.clientX); scheduleRelease(); }
function onPointerLeave() { scheduleRelease(); }
function onKeyDown(e: KeyboardEvent) {
if (e.key === "ArrowLeft" || e.key === "ArrowRight") {
e.preventDefault();
manual = true;
root!.classList.remove("is-demo");
const step = e.key === "ArrowLeft" ? -0.18 : 0.18;
pointerNormX = Math.max(-1, Math.min(1, pointerNormX + step));
scheduleRelease();
}
}
canvas.addEventListener("pointermove", onPointerMove);
canvas.addEventListener("pointerdown", onPointerDown);
canvas.addEventListener("pointerleave", onPointerLeave);
root.addEventListener("keydown", onKeyDown);
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let elapsed = 0;
if (reduce) root.classList.remove("is-demo");
const warmSteps = reduce ? 30 : 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();
}
raf = requestAnimationFrame(tick);
}
fit();
window.addEventListener("resize", fit);
draw();
raf = requestAnimationFrame(tick);
return () => {
cancelAnimationFrame(raf);
if (manualReleaseTimer) window.clearTimeout(manualReleaseTimer);
window.removeEventListener("resize", fit);
canvas.removeEventListener("pointermove", onPointerMove);
canvas.removeEventListener("pointerdown", onPointerDown);
canvas.removeEventListener("pointerleave", onPointerLeave);
root.removeEventListener("keydown", onKeyDown);
};
}, []);
return (
<main className={styles.stage}>
<div
className={`${styles.rn} is-demo`}
ref={rootRef}
tabIndex={0}
role="img"
aria-label="포인터를 좌우로 움직이거나 화살표 키를 누르면 비가 그쪽으로 눕는 장면"
>
<canvas className={styles.rn__canvas} ref={canvasRef} width={480} height={300} />
<p className={styles.rn__caption}>포인터가 화면 중심에서 벗어난 만큼 바람 벡터의 방향을 다시 계산해, 포인터를 움직이는 동안 빗줄기 전체가 그 방향으로 같이 기울어 떨어진다</p>
<p className={styles.rn__hint}>포인터를 움직이거나 ← → 키</p>
</div>
</main>
);
}07 Background Wind Leaning Toward the Pointer Position — Measured area 16.376% · 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


