09Noise flow distortion — the scroll value pushes a 2D noise field's offset value so particle paths bend into a smooth flowing path
As the scroll value grows, the noise field's offset value shifts, so a particle path at the same spot bends smoothly toward a different direction over time.
Best forA generative-feeling art background, a creative-portfolio background
valueNoiseSCROLL_WARPNOISE_SCALE

<main class="stage">
<div class="sp" id="sp">
<p class="sp__hint">
<svg class="sp__ico" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M224 128a96 96 0 1 1-96-96a96 96 0 0 1 96 96" opacity=".2"/><path d="M232 128a104 104 0 0 1-208 0c0-41 23.81-78.36 60.66-95.27a8 8 0 0 1 6.68 14.54C60.15 61.59 40 93.27 40 128a88 88 0 0 0 176 0c0-34.73-20.15-66.41-51.34-80.73a8 8 0 0 1 6.68-14.54C208.19 49.64 232 87 232 128"/></g></svg>
<span>Scroll velocity changes the flow field's speed, and position shifts the wave's phase.</span>
</p>
<div class="sp__viewport" id="viewport" tabindex="0" role="region" aria-label="Particle noise flow scroll area">
<canvas class="sp__cv" id="cv" aria-hidden="true"></canvas>
<b class="sp__val" id="val" aria-hidden="true">0%</b>
<div class="sp__track" aria-hidden="true"></div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 변형 3개는 이 위 세 변수만 바꾼다. 계산 겹(값-노이즈 흐름장)은 index.html 의 valueNoise()/step() 에 있다.
$duration: 2s;
$easing: $ease-out;
$color: $subject-pink;
@include stage($stage-ink);
.sp {
--particle-color: #{$color};
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-2;
width: min(400px, 92vw);
}
.sp__hint {
display: flex;
align-items: center;
gap: $sp-2;
margin: 0;
color: rgba($stage-paper, .78);
font-size: 12px;
font-weight: 500;
line-height: 1.3;
}
.sp__ico {
width: 16px;
height: 16px;
flex: none;
color: $color;
}
.sp__viewport {
position: relative;
width: 100%;
height: 176px;
overflow-y: auto;
overflow-x: hidden;
border-radius: $r-card;
background: rgba($stage-paper, .06);
touch-action: pan-y;
scrollbar-width: thin;
}
.sp__cv {
position: sticky;
top: 0;
left: 0;
display: block;
width: 100%;
height: 176px;
}
.sp__track {
height: 320%;
}
.sp__val {
position: absolute;
top: $sp-2;
right: $sp-2;
padding: $sp-1 $sp-2;
border-radius: $r-pill;
background: rgba($stage-paper, .18);
color: $stage-paper;
font-size: 11px;
font-weight: 700;
line-height: 1.6;
pointer-events: none;
transition: opacity $duration $easing;
}
@media (max-width: 340px) {
.sp__hint { font-size: 11px; }
.sp__viewport, .sp__cv { height: 120px; }
}
@media (prefers-reduced-motion: reduce) {
.sp__viewport { scroll-behavior: auto; }
.sp__val { transition: none; }
}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);
}
.sp {
--particle-color: #ff9ebb;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
width: min(400px, 92vw);
}
.sp__hint {
display: flex;
align-items: center;
gap: 8px;
margin: 0;
color: rgba(255, 255, 255, 0.78);
font-size: 12px;
font-weight: 500;
line-height: 1.3;
}
.sp__ico {
width: 16px;
height: 16px;
flex: none;
color: #ff9ebb;
}
.sp__viewport {
position: relative;
width: 100%;
height: 176px;
overflow-y: auto;
overflow-x: hidden;
border-radius: 16px;
background: rgba(255, 255, 255, 0.06);
touch-action: pan-y;
scrollbar-width: thin;
}
.sp__cv {
position: sticky;
top: 0;
left: 0;
display: block;
width: 100%;
height: 176px;
}
.sp__track {
height: 320%;
}
.sp__val {
position: absolute;
top: 8px;
right: 8px;
padding: 4px 8px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.18);
color: #ffffff;
font-size: 11px;
font-weight: 700;
line-height: 1.6;
pointer-events: none;
transition: opacity 2s ease-out;
}
@media (max-width: 340px) {
.sp__hint {
font-size: 11px;
}
.sp__viewport, .sp__cv {
height: 120px;
}
}
@media (prefers-reduced-motion: reduce) {
.sp__viewport {
scroll-behavior: auto;
}
.sp__val {
transition: none;
}
}(function () {
var root = document.getElementById('sp');
var viewport = document.getElementById('viewport');
var cv = document.getElementById('cv');
var ctx = cv.getContext('2d');
var val = document.getElementById('val');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var isDemo = true;
function makeLcg(seed) {
var s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
var rng = makeLcg(8675309);
var css = getComputedStyle(root);
var PARTICLE_COLOR = css.getPropertyValue('--particle-color').trim() || '#ff9ebb';
// 계산 겹: 자체 구현 2D 값-노이즈 흐름장(value-noise flow field) — 라이브러리 없이 순열 표를
// 직접 만들어(makeLcg 로 섞음) 격자 꼭짓점 해시값을 스무스스텝으로 보간한다. 그 값을 각도로 써서
// 입자마다 흐르는 방향을 정하므로 입자가 "따라 흐르는" 모습이 나온다.
var PERM_SIZE = 256;
var perm = (function () {
var p = [];
for (var i = 0; i < PERM_SIZE; i++) p.push(i);
for (var j = PERM_SIZE - 1; j > 0; j--) {
var k = Math.floor(rng() * (j + 1));
var t = p[j]; p[j] = p[k]; p[k] = t;
}
return p.concat(p);
})();
function hash2(ix, iy) {
var a = perm[(ix & 255)];
return perm[(a + iy) & 255] / 255;
}
function smooth(t) { return t * t * (3 - 2 * t); }
function valueNoise(x, y) {
var x0 = Math.floor(x), y0 = Math.floor(y);
var fx = smooth(x - x0), fy = smooth(y - y0);
var v00 = hash2(x0, y0), v10 = hash2(x0 + 1, y0);
var v01 = hash2(x0, y0 + 1), v11 = hash2(x0 + 1, y0 + 1);
var a = v00 + (v10 - v00) * fx;
var b = v01 + (v11 - v01) * fx;
return a + (b - a) * fy;
}
var NOISE_SCALE = 0.045; // 공간 주파수 — 낮을수록 큰 소용돌이 결
var TIME_GAIN = 0.00014; // 시간에 따라 장이 흘러가는 속도
var SCROLL_WARP = 3.4; // 스크롤 위치가 노이즈 좌표를 미는 양 (위상 이동)
var BASE_SPEED = 34; // 기본 흐름 속력 (px/s)
var FLOW_SPEED = 0.6; // 흐름 속력 배수 (변형 대상 상수)
var COUNT = 60;
var W = 0, H = 0, dpr = 1;
var dots = [];
function fit() {
var r = viewport.getBoundingClientRect();
dpr = window.devicePixelRatio || 1;
W = r.width; H = r.height;
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
if (dots.length === 0) initDots();
}
function initDots() {
dots = [];
for (var i = 0; i < COUNT; i++) {
dots.push({ x: rng() * W, y: rng() * H, r: 1.2 + rng() * 1.9 });
}
}
var scrollPos = 0, scrollVel = 0, scrollDir = 0, idleMs = 0, lastTop = 0, lastT = 0;
function sampleScroll(now) {
var top = viewport.scrollTop;
var max = Math.max(1, viewport.scrollHeight - viewport.clientHeight);
scrollPos = top / max;
var dt = lastT ? Math.max(1, now - lastT) : 16;
var v = (top - lastTop) / dt * 1000;
scrollVel += (v - scrollVel) * 0.3;
if (Math.abs(scrollVel) > 4) { scrollDir = scrollVel > 0 ? 1 : -1; idleMs = 0; } else { idleMs += dt; }
lastTop = top; lastT = now;
}
viewport.addEventListener('scroll', function () {
sampleScroll(performance.now());
if (reduce) paintStatic();
});
function human() { if (isDemo) { root.classList.remove('is-demo'); isDemo = false; } }
['wheel', 'touchstart', 'pointerdown'].forEach(function (evt) {
viewport.addEventListener(evt, human, { passive: true });
});
function fieldAngle(x, y, now) {
var nx = x * NOISE_SCALE + now * TIME_GAIN + scrollPos * SCROLL_WARP;
var ny = y * NOISE_SCALE;
return valueNoise(nx, ny) * Math.PI * 4;
}
function step(now, dtSec) {
var speedGain = 1 + Math.min(2.2, Math.abs(scrollVel) / 180);
for (var i = 0; i < dots.length; i++) {
var d = dots[i];
var ang = fieldAngle(d.x, d.y, now);
var speed = BASE_SPEED * FLOW_SPEED * speedGain;
d.x += Math.cos(ang) * speed * dtSec;
d.y += Math.sin(ang) * speed * dtSec;
if (d.x < 0) d.x += W; else if (d.x > W) d.x -= W;
if (d.y < 0) d.y += H; else if (d.y > H) d.y -= H;
}
}
function paint() {
ctx.clearRect(0, 0, W, H);
ctx.fillStyle = PARTICLE_COLOR;
for (var i = 0; i < dots.length; i++) {
var d = dots[i];
ctx.globalAlpha = 0.78;
ctx.beginPath(); ctx.arc(d.x, d.y, d.r, 0, Math.PI * 2); ctx.fill();
}
ctx.globalAlpha = 1;
}
function updateVal() { val.textContent = Math.round(scrollPos * 100) + '%'; }
function paintStatic() {
for (var i = 0; i < dots.length; i++) {
var d = dots[i];
var ang = fieldAngle(d.x, d.y, 0);
d.x += Math.cos(ang) * 6;
d.y += Math.sin(ang) * 6;
if (d.x < 0) d.x += W; else if (d.x > W) d.x -= W;
if (d.y < 0) d.y += H; else if (d.y > H) d.y -= H;
}
paint();
updateVal();
}
var lastFrameT = 0;
function frame(now) {
if (!lastFrameT) lastFrameT = now;
var dtSec = Math.min(0.05, (now - lastFrameT) / 1000);
lastFrameT = now;
step(now, dtSec);
paint();
updateVal();
requestAnimationFrame(frame);
}
var DRIVE_PERIOD_MS = 2000;
var driveStart = 0;
function triangle(frac) { return frac < 0.5 ? frac * 2 : 2 - frac * 2; }
function driveTick(now) {
if (!driveStart) driveStart = now;
if (isDemo && !reduce) {
var frac = ((now - driveStart) % DRIVE_PERIOD_MS) / DRIVE_PERIOD_MS;
var w = triangle(frac);
var max = Math.max(1, viewport.scrollHeight - viewport.clientHeight);
viewport.scrollTop = w * max;
}
requestAnimationFrame(driveTick);
}
window.addEventListener('resize', fit);
fit();
if (reduce) {
root.classList.add('is-still');
sampleScroll(performance.now());
paintStatic();
} else {
requestAnimationFrame(frame);
requestAnimationFrame(driveTick);
}
})();// NoiseFlow — vanilla 판(run/370/demo/09-scroll-particle-noise-flow/index.html) 의 React 포트.
// 계산 겹: 자체 구현 2D 값-노이즈 흐름장(value-noise flow field) — 라이브러리 없이 순열 표를
// 직접 만들어(makeLcg 로 섞음) 격자 꼭짓점 해시값을 스무스스텝으로 보간한다. 그 값을 각도로 써서
// 입자마다 흐르는 방향을 정하므로 입자가 "따라 흐르는" 모습이 나온다.
import { useEffect, useRef } from "react";
import styles from "./NoiseFlow.module.scss";
type Box<T> = { current: T };
interface Dot { x: number; y: number; r: number; }
export default function NoiseFlow() {
const rootRef = useRef(null) as unknown as Box<HTMLDivElement>;
const viewportRef = useRef(null) as unknown as Box<HTMLDivElement>;
const canvasRef = useRef(null) as unknown as Box<HTMLCanvasElement>;
const valRef = useRef(null) as unknown as Box<HTMLElement>;
const rafFrameRef = useRef(0) as unknown as Box<number>;
const rafDriveRef = useRef(0) as unknown as Box<number>;
useEffect(() => {
const root = rootRef.current;
const viewport = viewportRef.current;
const canvas = canvasRef.current;
const val = valRef.current;
if (!root || !viewport || !canvas || !val) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let isDemo = true;
function makeLcg(seed: number) {
let s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
const rng = makeLcg(8675309);
const css = getComputedStyle(root);
const PARTICLE_COLOR = css.getPropertyValue("--particle-color").trim() || "#ff9ebb";
const PERM_SIZE = 256;
const permBase: number[] = [];
for (let i = 0; i < PERM_SIZE; i++) permBase.push(i);
for (let j = PERM_SIZE - 1; j > 0; j--) {
const k = Math.floor(rng() * (j + 1));
const t = permBase[j]; permBase[j] = permBase[k]; permBase[k] = t;
}
const perm = permBase.concat(permBase);
function hash2(ix: number, iy: number) {
const a = perm[ix & 255];
return perm[(a + iy) & 255] / 255;
}
function smooth(t: number) { return t * t * (3 - 2 * t); }
function valueNoise(x: number, y: number) {
const x0 = Math.floor(x), y0 = Math.floor(y);
const fx = smooth(x - x0), fy = smooth(y - y0);
const v00 = hash2(x0, y0), v10 = hash2(x0 + 1, y0);
const v01 = hash2(x0, y0 + 1), v11 = hash2(x0 + 1, y0 + 1);
const a = v00 + (v10 - v00) * fx;
const b = v01 + (v11 - v01) * fx;
return a + (b - a) * fy;
}
const NOISE_SCALE = 0.045;
const TIME_GAIN = 0.00014;
const SCROLL_WARP = 3.4;
const BASE_SPEED = 34;
const FLOW_SPEED = 0.6;
const COUNT = 60;
let W = 0, H = 0, dpr = 1;
let dots: Dot[] = [];
function initDots() {
dots = [];
for (let i = 0; i < COUNT; i++) {
dots.push({ x: rng() * W, y: rng() * H, r: 1.2 + rng() * 1.9 });
}
}
function fit() {
const r = viewport!.getBoundingClientRect();
dpr = window.devicePixelRatio || 1;
W = r.width; H = r.height;
canvas!.width = Math.round(W * dpr);
canvas!.height = Math.round(H * dpr);
ctx!.setTransform(dpr, 0, 0, dpr, 0, 0);
if (dots.length === 0) initDots();
}
let scrollPos = 0, scrollVel = 0, scrollDir = 0, idleMs = 0, lastTop = 0, lastT = 0;
function sampleScroll(now: number) {
const top = viewport!.scrollTop;
const max = Math.max(1, viewport!.scrollHeight - viewport!.clientHeight);
scrollPos = top / max;
const dt = lastT ? Math.max(1, now - lastT) : 16;
const v = (top - lastTop) / dt * 1000;
scrollVel += (v - scrollVel) * 0.3;
if (Math.abs(scrollVel) > 4) { scrollDir = scrollVel > 0 ? 1 : -1; idleMs = 0; } else { idleMs += dt; }
lastTop = top; lastT = now;
}
function onScroll() {
sampleScroll(performance.now());
if (reduce) paintStatic();
}
viewport.addEventListener("scroll", onScroll);
function human() { if (isDemo) { isDemo = false; } }
viewport.addEventListener("wheel", human, { passive: true });
viewport.addEventListener("touchstart", human, { passive: true });
viewport.addEventListener("pointerdown", human, { passive: true });
function fieldAngle(x: number, y: number, now: number) {
const nx = x * NOISE_SCALE + now * TIME_GAIN + scrollPos * SCROLL_WARP;
const ny = y * NOISE_SCALE;
return valueNoise(nx, ny) * Math.PI * 4;
}
function step(now: number, dtSec: number) {
const speedGain = 1 + Math.min(2.2, Math.abs(scrollVel) / 180);
for (let i = 0; i < dots.length; i++) {
const d = dots[i];
const ang = fieldAngle(d.x, d.y, now);
const speed = BASE_SPEED * FLOW_SPEED * speedGain;
d.x += Math.cos(ang) * speed * dtSec;
d.y += Math.sin(ang) * speed * dtSec;
if (d.x < 0) d.x += W; else if (d.x > W) d.x -= W;
if (d.y < 0) d.y += H; else if (d.y > H) d.y -= H;
}
}
function paint() {
ctx!.clearRect(0, 0, W, H);
ctx!.fillStyle = PARTICLE_COLOR;
for (let i = 0; i < dots.length; i++) {
const d = dots[i];
ctx!.globalAlpha = 0.78;
ctx!.beginPath(); ctx!.arc(d.x, d.y, d.r, 0, Math.PI * 2); ctx!.fill();
}
ctx!.globalAlpha = 1;
}
function updateVal() { val!.textContent = Math.round(scrollPos * 100) + "%"; }
function paintStatic() {
for (let i = 0; i < dots.length; i++) {
const d = dots[i];
const ang = fieldAngle(d.x, d.y, 0);
d.x += Math.cos(ang) * 6;
d.y += Math.sin(ang) * 6;
if (d.x < 0) d.x += W; else if (d.x > W) d.x -= W;
if (d.y < 0) d.y += H; else if (d.y > H) d.y -= H;
}
paint();
updateVal();
}
let lastFrameT = 0;
function frame(now: number) {
if (!lastFrameT) lastFrameT = now;
const dtSec = Math.min(0.05, (now - lastFrameT) / 1000);
lastFrameT = now;
step(now, dtSec);
paint();
updateVal();
rafFrameRef.current = requestAnimationFrame(frame);
}
const DRIVE_PERIOD_MS = 2000;
let driveStart = 0;
function triangle(frac: number) { return frac < 0.5 ? frac * 2 : 2 - frac * 2; }
function driveTick(now: number) {
if (!driveStart) driveStart = now;
if (isDemo && !reduce) {
const frac = ((now - driveStart) % DRIVE_PERIOD_MS) / DRIVE_PERIOD_MS;
const w = triangle(frac);
const max = Math.max(1, viewport!.scrollHeight - viewport!.clientHeight);
viewport!.scrollTop = w * max;
}
rafDriveRef.current = requestAnimationFrame(driveTick);
}
function onResize() { fit(); }
window.addEventListener("resize", onResize);
fit();
if (reduce) {
sampleScroll(performance.now());
paintStatic();
} else {
rafFrameRef.current = requestAnimationFrame(frame);
rafDriveRef.current = requestAnimationFrame(driveTick);
}
return () => {
window.removeEventListener("resize", onResize);
viewport.removeEventListener("scroll", onScroll);
viewport.removeEventListener("wheel", human);
viewport.removeEventListener("touchstart", human);
viewport.removeEventListener("pointerdown", human);
cancelAnimationFrame(rafFrameRef.current);
cancelAnimationFrame(rafDriveRef.current);
};
}, []);
return (
<main className={styles.stage}>
<div className={styles.root} ref={rootRef}>
<p className={styles.hint}>
<svg className={styles.icon} viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M224 128a96 96 0 1 1-96-96a96 96 0 0 1 96 96" opacity=".2" /><path d="M232 128a104 104 0 0 1-208 0c0-41 23.81-78.36 60.66-95.27a8 8 0 0 1 6.68 14.54C60.15 61.59 40 93.27 40 128a88 88 0 0 0 176 0c0-34.73-20.15-66.41-51.34-80.73a8 8 0 0 1 6.68-14.54C208.19 49.64 232 87 232 128" /></g></svg>
<span>스크롤 속도가 흐름장의 빠르기를, 위치가 물결의 위상을 바꿉니다</span>
</p>
<div className={styles.viewport} ref={viewportRef} tabIndex={0} role="region" aria-label="입자 노이즈 흐름 스크롤 영역">
<canvas className={styles.canvas} ref={canvasRef} aria-hidden="true" />
<b className={styles.val} ref={valRef} aria-hidden="true">0%</b>
<div className={styles.track} aria-hidden="true" />
</div>
</div>
</main>
);
}09 Noise flow distortion — the scroll value pushes a 2D noise field's offset value so particle paths bend into a smooth flowing path — Measured area 13.367% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · Moving Particles Background CSS: 9 Scroll Scenes
The post this demo lives in
Moving Particles Background CSS: 9 Scroll ScenesNine moving particles background CSS scenes: one speeds spawn with scroll velocity, one shifts each particle by depth, a See all demos in this post
If you like this effect - similar demos
Scroll08 A Swarm of Dots That Flows Along the Path With Scroll SpeedScroll Path Animation: 9 Scenes Along a Curve
Scroll07 Progress written into the browser tab title9 Reading Progress Bar CSS Patterns, No Library
Scroll01 Scroll inertia coast — the position value keeps carrying the last scroll speed and gradually shrinks to zero after scrolling stopsScroll Inertia CSS — 9 Value Patterns