パーティクル 背景 9選 — スクロール値で流れが変わるキャンバスシーン
パーティクル 背景: 9つのうち1つはスクロール速度の値が速くなるほどパーティクルが生成される頻度の値が増え、1つはパーティクルごとの奥行きの値によって同じスクロール移動量が異なる距離で適用され、もう1つはスクロール位置の値がある地点に近づくほどその場の渦の吸引力の値が大きくなります。
01 scroll-particle-drift-field
02 scroll-particle-velocity-spawn
03 scroll-particle-direction-reverse
04 scroll-particle-depth-parallax
05 scroll-particle-idle-settle
06 scroll-particle-burst-flick
07 scroll-particle-hue-shift
08 scroll-particle-vortex-pull
09 scroll-particle-noise-flow
自動再生 · タイルを押すとその項目へ · すべて1つのzip
- 01 基本フロー場 — スクロールの値がなくてもパーティクルが基準速度の値で静かに上へ流れる背景の流れ
- 02 速度連動生成 — スクロール速度の値が速くなるほど新しく生成されるパーティクルの頻度の値が増える方式
- 03 方向反転 — スクロール方向の値が下から上に変わるとパーティクルの流れの方向の値も即座に逆になる方式
- 04 奥行き遠近レイヤー — パーティクルごとに異なる奥行きの値を持ち、同じスクロール移動量が奥行きの値によって異なる距離で適用される方式
- 05 停止沈降 — スクロールが止まった瞬間、パーティクルが重力の値の影響を受けて徐々に下へ沈む方式
- 06 フリックバースト — 短時間でスクロールの値が大きく跳ねると、その地点でパーティクルが瞬間的に飛び散る方式
- 07 区間別色相転換 — スクロール進行率の値が区間を超えるたびにパーティクルの色の値が次の区間の色に変わる方式
- 08 渦の吸引 — スクロール位置の値が特定の地点に近づくほど、その地点の渦の吸引力の値が大きくなる方式
- 09 ノイズフロー歪み — スクロールの値が2Dノイズ場のオフセットの値を押し流し、パーティクルの経路が滑らかに曲がる流れに変わる方式
9つとも通信を一切行わず、localStorageへの書き込みもなく、Math.randomも使いません。読み取る入力はスクロールの値(位置・速度・方向のいずれか)一つだけで、各シーンでそれに応えるパーティクルの性質もいつも一つだけです——上昇速度の値か、生成頻度の値か、レーンの方向の値か、奥行きのオフセット値か、ばねの目標値か、バーストの個数の値か、色相の値か、吸引半径の値か、ノイズのオフセット値かのいずれかです。この範囲の狭さのおかげで、パーティクル 背景 素材として使う9つのシーンのどれが目的のセクションに合うか、実際に動く性質の名前を知るだけでほぼ判断でき、下のコードは仕組みを裏付けるだけで済みます。また9つともctx.clearRect()だけで前フレームを消し、背景を塗りつぶすことはないため、パーティクル 背景透過のまま下のセクションに重ねて使えます。
9つのパターンの解剖 — どの値がスクロールに反応するか
01番のstep()はスクロール速度をWIND_GAINで拡大した値とFIELD_FREQのサイン場でRISE_SPEEDの値を横に曲げながらパーティクルを上昇させます。02番のstep()はスクロール速度をSPAWN_GAINによる生成率に変換し、各パーティクルは発射後FRICTIONの値で減速します。03番のstep()はlaneSignとdirSignの組み合わせを読み取り、スクロール方向が反転した瞬間にレーンごとのLANE_SPEEDでの流れの向きを反転させます。04番のproject()はFOVと奥行きの割り算をDOLLY_RANGEで拡大したcameraZ()にかけて処理し、同じスクロール距離でも浅いパーティクルほど大きく、深いパーティクルほど小さく動かします。05番のstep()はスクロールが止まるとSTIFFNESSのばねをSETTLE_DAMPの値で減衰させながら静止値へ戻し、idleMsの値が止まっている時間を数えます。06番のsampleScroll()はスクロール速度の値がFLICK_THRESHOLDを下から上へ超える立ち上がりを監視してburst()を呼び出します。07番のbandColor()はスクロール進行率の値が区切りを超えるたびにBAND_A・BAND_B・BAND_Cの3色帯を補間します。08番のstep()はスクロール位置の値が地点に近づくほどREST_SHRINKの値で静止半径を縮め、その半径(下限8px)でVORTEX_Kを割った角速度で内側へ回転させます。09番のstep()は自作のvalueNoise()——256個の要素を持つ順列テーブルをハッシュして滑らかにした値であってMath.randomの呼び出しではありません——をSCROLL_WARPとNOISE_SCALEでスクロールの値だけ押し流して読み取ります。9つとも自分のスクロールコンテナに対してscrollとresizeを監視し、wheel・touchstart・pointerdownのいずれかが来た瞬間を実際の訪問者と見なしてhuman()を呼び、.is-demoの自動再生を完全に止めます。それまでの間、同じ自動再生がdriveTick()という同一の形の三角波でscrollTopの値を動かします。自動再生も各シーン自身のstep()も、タイマーではなくすべてrequestAnimationFrameを通じて動き、Math.randomは9つのどこにも使われていません——代わりにそれぞれが小さな線形合同法の乱数生成器を一つだけ種として持ち、同じ種なら常に同じ初期配置になります。prefers-reduced-motion: reduceの下では、9つともそのフレームごとのループを完全に飛ばしてpaintStatic()を一度だけ呼び出すので、モーション低減を求める訪問者にも空のキャンバスではなく静止した配置が見えます。
パターン別コード
01基本フロー場 — スクロールの値がなくてもパーティクルが基準速度の値で静かに上へ流れる背景の流れ
スクロールの値が0でもパーティクルは基準速度の値で上へ流れ続け、スクロールの値が生まれるとその流れの上に他の値が重なります。WIND_GAINで拡大したスクロール速度の値が場全体を横へ押し、FIELD_FREQのサイン場がパーティクルごとの揺れを加えるので、9つ別々の流れではなく一つの穏やかな流れとして見えます——ヒーローセクションや静かなローディング背景に向いています。

<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>スクロールを上げると、基準の流れの上に風の値が重なります。</span>
</p>
<div class="sp__viewport" id="viewport" tabindex="0" role="region" aria-label="パーティクルフロー スクロール領域">
<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 의 step() 에 있다.
$duration: 2s;
$easing: $ease-out;
$color: $subject-sky;
@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($subject-cream, .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($subject-cream, .04);
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-ink, .48);
color: $subject-cream;
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: #6ec8ff;
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, 247, 230, 0.78);
font-size: 12px;
font-weight: 500;
line-height: 1.3;
}
.sp__ico {
width: 16px;
height: 16px;
flex: none;
color: #6ec8ff;
}
.sp__viewport {
position: relative;
width: 100%;
height: 176px;
overflow-y: auto;
overflow-x: hidden;
border-radius: 16px;
background: rgba(255, 247, 230, 0.04);
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(23, 20, 26, 0.48);
color: #fff7e6;
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;
// 결정론 난수 — 이 데모는 Math.random 을 쓰지 않는다. LCG(선형합동생성기) 하나로 파티클을 배치한다.
function makeLcg(seed) {
var s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
var rng = makeLcg(9001);
var css = getComputedStyle(root);
var PARTICLE_COLOR = css.getPropertyValue('--particle-color').trim() || '#6ec8ff';
// 계산 겹: 속도장(velocity field) — 상승 속도는 상수, 가로 바람은 위치(y)마다 다른 위상의 사인파다.
// 스크롤 속도 값이 이 바람의 세기를 정한다. 스크롤이 0이어도 상승은 멈추지 않는다.
var RISE_SPEED = 26; // 기준 상승 속도 (px/s)
var WIND_GAIN = 1.6; // 스크롤 속도(px/s) → 가로 바람(px/s) 변환 계수
var FIELD_FREQ = 0.02; // 속도장의 공간 주파수 — 높이마다 바람 방향이 달라진다
var SWAY_AMP = 9; // 속도장 자체의 흔들림 진폭 (px/s)
var COUNT = 46;
var W = 0, H = 0, dpr = 1;
var particles = [];
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);
}
function spawnAt(p, first) {
p.x = rng() * W;
p.y = first ? rng() * H : H + rng() * 24;
p.r = 1.3 + rng() * 2.4;
p.speedVar = 0.6 + rng() * 0.8;
p.phase = rng() * Math.PI * 2;
p.alpha = 0.34 + rng() * 0.5;
}
function initParticles() {
particles = [];
for (var i = 0; i < COUNT; i++) { var p = {}; spawnAt(p, true); particles.push(p); }
}
// ── 스크롤 표본기 — 위치·속도·방향·정지시간을 한 곳에서 잰다(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 step(dtSec) {
var wind = scrollVel * WIND_GAIN;
for (var i = 0; i < particles.length; i++) {
var p = particles[i];
p.y -= RISE_SPEED * p.speedVar * dtSec;
var sway = Math.sin(p.y * FIELD_FREQ + p.phase) * SWAY_AMP;
p.x += (wind + sway) * dtSec;
if (p.y < -8) spawnAt(p, false);
if (p.x < -8) p.x = W + 8; else if (p.x > W + 8) p.x = -8;
}
}
function paint() {
ctx.clearRect(0, 0, W, H);
ctx.fillStyle = PARTICLE_COLOR;
for (var i = 0; i < particles.length; i++) {
var p = particles[i];
ctx.globalAlpha = p.alpha;
ctx.beginPath(); ctx.arc(p.x, p.y, p.r, 0, Math.PI * 2); ctx.fill();
}
ctx.globalAlpha = 1;
}
function updateVal() { val.textContent = Math.round(scrollPos * 100) + '%'; }
function paintStatic() {
// reduce — 시간 적분을 멈추고 스크롤 위치 값만 가로 오프셋으로 반영한 정지 장면
ctx.clearRect(0, 0, W, H);
ctx.fillStyle = PARTICLE_COLOR;
for (var i = 0; i < particles.length; i++) {
var p = particles[i];
var sway = Math.sin(p.y * FIELD_FREQ + p.phase) * SWAY_AMP * 0.06;
var x = ((p.x + sway + scrollPos * 30) % W + W) % W;
ctx.globalAlpha = p.alpha;
ctx.beginPath(); ctx.arc(x, p.y, p.r, 0, Math.PI * 2); ctx.fill();
}
ctx.globalAlpha = 1;
updateVal();
}
var lastFrameT = 0;
function frame(now) {
if (!lastFrameT) lastFrameT = now;
var dtSec = Math.min(0.05, (now - lastFrameT) / 1000);
lastFrameT = now;
step(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();
initParticles();
if (reduce) {
root.classList.add('is-still');
sampleScroll(performance.now());
paintStatic();
} else {
requestAnimationFrame(frame);
requestAnimationFrame(driveTick);
}
})();// DriftField — vanilla 판(run/370/demo/01-scroll-particle-drift-field/index.html) 의 React 포트.
// 계산 겹: 속도장(velocity field) — 상승 속도는 상수, 가로 바람은 위치(y)마다 다른 위상의 사인파다.
// 스크롤 속도 값이 이 바람의 세기를 정한다. 스크롤이 0이어도 상승은 멈추지 않는다.
import { useEffect, useRef } from "react";
import styles from "./DriftField.module.scss";
type Box<T> = { current: T };
interface Particle { x: number; y: number; r: number; speedVar: number; phase: number; alpha: number; }
export default function DriftField() {
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(9001);
const css = getComputedStyle(root);
const PARTICLE_COLOR = css.getPropertyValue("--particle-color").trim() || "#6ec8ff";
const RISE_SPEED = 26;
const WIND_GAIN = 1.6;
const FIELD_FREQ = 0.02;
const SWAY_AMP = 9;
const COUNT = 46;
let W = 0, H = 0, dpr = 1;
let particles: Particle[] = [];
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);
}
function spawnAt(p: Particle, first: boolean) {
p.x = rng() * W;
p.y = first ? rng() * H : H + rng() * 24;
p.r = 1.3 + rng() * 2.4;
p.speedVar = 0.6 + rng() * 0.8;
p.phase = rng() * Math.PI * 2;
p.alpha = 0.34 + rng() * 0.5;
}
function initParticles() {
particles = [];
for (let i = 0; i < COUNT; i++) {
const p: Particle = { x: 0, y: 0, r: 0, speedVar: 0, phase: 0, alpha: 0 };
spawnAt(p, true);
particles.push(p);
}
}
let scrollPos = 0, scrollVel = 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) { 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 step(dtSec: number) {
const wind = scrollVel * WIND_GAIN;
for (let i = 0; i < particles.length; i++) {
const p = particles[i];
p.y -= RISE_SPEED * p.speedVar * dtSec;
const sway = Math.sin(p.y * FIELD_FREQ + p.phase) * SWAY_AMP;
p.x += (wind + sway) * dtSec;
if (p.y < -8) spawnAt(p, false);
if (p.x < -8) p.x = W + 8; else if (p.x > W + 8) p.x = -8;
}
}
function paint() {
ctx!.clearRect(0, 0, W, H);
ctx!.fillStyle = PARTICLE_COLOR;
for (let i = 0; i < particles.length; i++) {
const p = particles[i];
ctx!.globalAlpha = p.alpha;
ctx!.beginPath(); ctx!.arc(p.x, p.y, p.r, 0, Math.PI * 2); ctx!.fill();
}
ctx!.globalAlpha = 1;
}
function updateVal() { val!.textContent = Math.round(scrollPos * 100) + "%"; }
function paintStatic() {
ctx!.clearRect(0, 0, W, H);
ctx!.fillStyle = PARTICLE_COLOR;
for (let i = 0; i < particles.length; i++) {
const p = particles[i];
const sway = Math.sin(p.y * FIELD_FREQ + p.phase) * SWAY_AMP * 0.06;
const x = ((p.x + sway + scrollPos * 30) % W + W) % W;
ctx!.globalAlpha = p.alpha;
ctx!.beginPath(); ctx!.arc(x, p.y, p.r, 0, Math.PI * 2); ctx!.fill();
}
ctx!.globalAlpha = 1;
updateVal();
}
let lastFrameT = 0;
function frame(now: number) {
if (!lastFrameT) lastFrameT = now;
const dtSec = Math.min(0.05, (now - lastFrameT) / 1000);
lastFrameT = now;
step(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();
initParticles();
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>
);
}01 · 基本フロー場 — スクロールの値がなくてもパーティクルが基準速度の値で静かに上へ流れる背景の流れ — 計測 累積面積 15.706% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
function step(dtSec) {
var wind = scrollVel * WIND_GAIN;
for (var i = 0; i < particles.length; i++) {
var p = particles[i];
p.y -= RISE_SPEED * p.speedVar * dtSec;
var sway = Math.sin(p.y * FIELD_FREQ + p.phase) * SWAY_AMP;
p.x += (wind + sway) * dtSec;
if (p.y < -8) spawnAt(p, false);
if (p.x < -8) p.x = W + 8; else if (p.x > W + 8) p.x = -8;
}
}
数値が語ること
01番はフレームごとに累積面積の15.71%を平均強度88.9で占め、23フレーム中23フレームで動きます——9つの中で最も高い累積面積です。静止したスクロールの間も上昇の流れが止まらず、サンプリングしたほぼ全てのフレームで再描画が続くからです。
02速度連動生成 — スクロール速度の値が速くなるほど新しく生成されるパーティクルの頻度の値が増える方式
スクロール速度の値が速くなるほど1フレームで生成されるパーティクル数の値が増え、止まると生成頻度の値は基準値に戻ります。SPAWN_GAINが速度への反応の鋭さを決め、LAUNCH_GAINが新しいパーティクルの初速を決め、FRICTIONがその初速を発射後に減衰させます——スクロール中だけ賑やかに見せたいヒーローバナーに向いています。

<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>速くスクロールするほど、パーティクルはより多く、より速く飛び出します。</span>
</p>
<div class="sp__viewport" id="viewport" tabindex="0" role="region" aria-label="パーティクル生成 スクロール領域">
<canvas class="sp__cv" id="cv" aria-hidden="true"></canvas>
<b class="sp__val" id="val" aria-hidden="true">0px/s</b>
<div class="sp__track" aria-hidden="true"></div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 변형 3개는 이 위 세 변수만 바꾼다. 계산 겹(분사율·마찰)은 index.html 의 step() 에 있다.
$duration: 2s;
$easing: $ease-out;
$color: $subject-orange-deep;
@include stage($stage-yellow);
.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-ink, .78);
font-size: 12px;
font-weight: 500;
line-height: 1.3;
}
.sp__ico {
width: 16px;
height: 16px;
flex: none;
color: $subject-orange-deep;
}
.sp__viewport {
position: relative;
width: 100%;
height: 176px;
overflow-y: auto;
overflow-x: hidden;
border-radius: $r-card;
background: rgba($stage-ink, .08);
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-ink, .5);
color: $subject-cream;
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: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.sp {
--particle-color: #c93000;
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(23, 20, 26, 0.78);
font-size: 12px;
font-weight: 500;
line-height: 1.3;
}
.sp__ico {
width: 16px;
height: 16px;
flex: none;
color: #c93000;
}
.sp__viewport {
position: relative;
width: 100%;
height: 176px;
overflow-y: auto;
overflow-x: hidden;
border-radius: 16px;
background: rgba(23, 20, 26, 0.08);
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(23, 20, 26, 0.5);
color: #fff7e6;
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(4271);
var css = getComputedStyle(root);
var PARTICLE_COLOR = css.getPropertyValue('--particle-color').trim() || '#ffd23f';
// 계산 겹: 분사율 매핑(spawn-rate mapping) — 스크롤 속도(px/s)의 절대값이 초당 입자 발생 수를 정하고,
// 부호가 발사 방향을 정한다. 발사된 입자는 마찰(FRICTION)로 속도가 지수적으로 줄며 사라진다.
var SPAWN_GAIN = 0.1; // |스크롤 속도| → 초당 입자 수
var LAUNCH_GAIN = 0.95; // 스크롤 속도 → 입자 발사 속도(px/s)
var FRICTION = 1.15; // 속도 감쇠율 (1/s)
var LIFE_MS = 1300;
var MAX_PARTICLES = 150;
var W = 0, H = 0, dpr = 1;
var particles = [];
var spawnAcc = 0;
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);
}
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.35;
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 spawnOne(now) {
var p = {
x: W * 0.5 + (rng() - 0.5) * 20,
y: H * 0.5 + (rng() - 0.5) * 20,
vx: scrollVel * LAUNCH_GAIN + (rng() - 0.5) * 40,
vy: (rng() - 0.5) * 60 - scrollDir * 20,
r: 2 + rng() * 3.2,
born: now
};
particles.push(p);
if (particles.length > MAX_PARTICLES) particles.shift();
}
function step(now, dtSec) {
var rate = Math.min(140, Math.abs(scrollVel) * SPAWN_GAIN);
spawnAcc += rate * dtSec;
while (spawnAcc >= 1) { spawnOne(now); spawnAcc -= 1; }
var decay = Math.exp(-FRICTION * dtSec);
for (var i = particles.length - 1; i >= 0; i--) {
var p = particles[i];
p.vx *= decay; p.vy *= decay;
p.x += p.vx * dtSec;
p.y += p.vy * dtSec;
if (now - p.born > LIFE_MS) particles.splice(i, 1);
}
}
function paint(now) {
ctx.clearRect(0, 0, W, H);
ctx.fillStyle = PARTICLE_COLOR;
for (var i = 0; i < particles.length; i++) {
var p = particles[i];
var t = (now - p.born) / LIFE_MS;
ctx.globalAlpha = Math.max(0, 1 - t) * 0.85;
ctx.beginPath(); ctx.arc(p.x, p.y, p.r * (1 - t * 0.4), 0, Math.PI * 2); ctx.fill();
}
ctx.globalAlpha = 1;
}
function updateVal() { val.textContent = Math.round(Math.abs(scrollVel)) + 'px/s'; }
function paintStatic() {
ctx.clearRect(0, 0, W, H);
ctx.fillStyle = PARTICLE_COLOR;
var n = 10 + Math.round(scrollPos * 40);
for (var i = 0; i < n; i++) {
var ang = (i / n) * Math.PI * 2;
var rad = 12 + (i % 5) * 10 + scrollPos * 20;
var x = W * 0.5 + Math.cos(ang) * rad;
var y = H * 0.5 + Math.sin(ang) * rad * 0.6;
ctx.globalAlpha = 0.6;
ctx.beginPath(); ctx.arc(x, y, 2, 0, Math.PI * 2); ctx.fill();
}
ctx.globalAlpha = 1;
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(now);
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);
}
})();// VelocitySpawn — vanilla 판(run/370/demo/02-scroll-particle-velocity-spawn/index.html) 의 React 포트.
// 계산 겹: 분사율 매핑(spawn-rate mapping) — 스크롤 속도(px/s)의 절대값이 초당 입자 발생 수를 정하고,
// 부호가 발사 방향을 정한다. 발사된 입자는 마찰(FRICTION)로 속도가 지수적으로 줄며 사라진다.
import { useEffect, useRef } from "react";
import styles from "./VelocitySpawn.module.scss";
type Box<T> = { current: T };
interface Particle { x: number; y: number; vx: number; vy: number; r: number; born: number; }
export default function VelocitySpawn() {
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(4271);
const css = getComputedStyle(root);
const PARTICLE_COLOR = css.getPropertyValue("--particle-color").trim() || "#ffd23f";
const SPAWN_GAIN = 0.1;
const LAUNCH_GAIN = 0.95;
const FRICTION = 1.15;
const LIFE_MS = 1300;
const MAX_PARTICLES = 150;
let W = 0, H = 0, dpr = 1;
let particles: Particle[] = [];
let spawnAcc = 0;
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);
}
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.35;
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 spawnOne(now: number) {
const p: Particle = {
x: W * 0.5 + (rng() - 0.5) * 20,
y: H * 0.5 + (rng() - 0.5) * 20,
vx: scrollVel * LAUNCH_GAIN + (rng() - 0.5) * 40,
vy: (rng() - 0.5) * 60 - scrollDir * 20,
r: 2 + rng() * 3.2,
born: now,
};
particles.push(p);
if (particles.length > MAX_PARTICLES) particles.shift();
}
function step(now: number, dtSec: number) {
const rate = Math.min(140, Math.abs(scrollVel) * SPAWN_GAIN);
spawnAcc += rate * dtSec;
while (spawnAcc >= 1) { spawnOne(now); spawnAcc -= 1; }
const decay = Math.exp(-FRICTION * dtSec);
for (let i = particles.length - 1; i >= 0; i--) {
const p = particles[i];
p.vx *= decay; p.vy *= decay;
p.x += p.vx * dtSec;
p.y += p.vy * dtSec;
if (now - p.born > LIFE_MS) particles.splice(i, 1);
}
}
function paint(now: number) {
ctx!.clearRect(0, 0, W, H);
ctx!.fillStyle = PARTICLE_COLOR;
for (let i = 0; i < particles.length; i++) {
const p = particles[i];
const t = (now - p.born) / LIFE_MS;
ctx!.globalAlpha = Math.max(0, 1 - t) * 0.85;
ctx!.beginPath(); ctx!.arc(p.x, p.y, p.r * (1 - t * 0.4), 0, Math.PI * 2); ctx!.fill();
}
ctx!.globalAlpha = 1;
}
function updateVal() { val!.textContent = Math.round(Math.abs(scrollVel)) + "px/s"; }
function paintStatic() {
ctx!.clearRect(0, 0, W, H);
ctx!.fillStyle = PARTICLE_COLOR;
const n = 10 + Math.round(scrollPos * 40);
for (let i = 0; i < n; i++) {
const ang = (i / n) * Math.PI * 2;
const rad = 12 + (i % 5) * 10 + scrollPos * 20;
const x = W * 0.5 + Math.cos(ang) * rad;
const y = H * 0.5 + Math.sin(ang) * rad * 0.6;
ctx!.globalAlpha = 0.6;
ctx!.beginPath(); ctx!.arc(x, y, 2, 0, Math.PI * 2); ctx!.fill();
}
ctx!.globalAlpha = 1;
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(now);
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">0px/s</b>
<div className={styles.track} aria-hidden="true" />
</div>
</div>
</main>
);
}02 · 速度連動生成 — スクロール速度の値が速くなるほど新しく生成されるパーティクルの頻度の値が増える方式 — 計測 累積面積 8.292% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
function step(now, dtSec) {
var rate = Math.min(140, Math.abs(scrollVel) * SPAWN_GAIN);
spawnAcc += rate * dtSec;
while (spawnAcc >= 1) { spawnOne(now); spawnAcc -= 1; }
var decay = Math.exp(-FRICTION * dtSec);
for (var i = particles.length - 1; i >= 0; i--) {
var p = particles[i];
p.vx *= decay; p.vy *= decay;
p.x += p.vx * dtSec;
p.y += p.vy * dtSec;
if (now - p.born > LIFE_MS) particles.splice(i, 1);
}
}
数値が語ること
02番は累積面積の8.29%を平均強度85.1で占め、23フレーム中23フレームで動きます。生成のたびにLIFE_MSを超えたパーティクルも同時に取り除かれるので、面積は増え続けるのではなくスクロール速度に合わせて上下します。
03方向反転 — スクロール方向の値が下から上に変わるとパーティクルの流れの方向の値も即座に逆になる方式
スクロール方向の値が上に変わった瞬間、パーティクルが流れていた方向の値が反転し、同じ場所でも上下で流れが変わります。laneSignとdirSignの組み合わせが各レーンの流れる向きを決め、LANE_SPEEDで速度を保ったまま、方向が反転すると全レーンが一斉に切り替わります。

<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>上下にスクロールする方向によって、交差する模様が反転します。</span>
</p>
<div class="sp__viewport" id="viewport" tabindex="0" role="region" aria-label="パーティクル方向 スクロール領域">
<canvas class="sp__cv" id="cv" aria-hidden="true"></canvas>
<b class="sp__val" id="val" aria-hidden="true">↓</b>
<div class="sp__track" aria-hidden="true"></div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 변형 3개는 이 위 세 변수만 바꾼다. 계산 겹(방향 부호장)은 index.html 의 step() 에 있다.
$duration: 2s;
$easing: $ease-out;
$color: $stage-ink;
@include stage($stage-orange);
.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-ink, .78);
font-size: 12px;
font-weight: 500;
line-height: 1.3;
}
.sp__ico {
width: 16px;
height: 16px;
flex: none;
color: $stage-ink;
}
.sp__viewport {
position: relative;
width: 100%;
height: 176px;
overflow-y: auto;
overflow-x: hidden;
border-radius: $r-card;
background: rgba($stage-ink, .07);
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-ink, .5);
color: $subject-cream;
font-size: 12px;
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: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.sp {
--particle-color: #17141a;
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(23, 20, 26, 0.78);
font-size: 12px;
font-weight: 500;
line-height: 1.3;
}
.sp__ico {
width: 16px;
height: 16px;
flex: none;
color: #17141a;
}
.sp__viewport {
position: relative;
width: 100%;
height: 176px;
overflow-y: auto;
overflow-x: hidden;
border-radius: 16px;
background: rgba(23, 20, 26, 0.07);
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(23, 20, 26, 0.5);
color: #fff7e6;
font-size: 12px;
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(7331);
var css = getComputedStyle(root);
var PARTICLE_COLOR = css.getPropertyValue('--particle-color').trim() || '#17141a';
// 계산 겹: 방향 부호장(direction-sign field) — 파티클은 레인마다 고정된 부호(lane sign)를 갖고,
// 실제 가로 속도는 그 부호에 스크롤 방향 부호를 곱한 값이다. 방향이 바뀌면 레인의 엇갈림이 통째로 뒤집힌다.
var LANES = 5;
var LANE_SPEED = 46; // 레인 가로 속도 크기 (px/s)
var RISE_SPEED = 20; // 기준 상승 속도 (px/s)
var COUNT = 40;
var W = 0, H = 0, dpr = 1;
var particles = [];
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);
}
function laneSign(lane) { return (lane % 2 === 0) ? 1 : -1; }
function spawnAt(p, first) {
var lane = Math.floor(rng() * LANES);
p.lane = lane;
p.x = (lane + 0.5) / LANES * W + (rng() - 0.5) * (W / LANES) * 0.6;
p.y = first ? rng() * H : H + rng() * 20;
p.r = 1.4 + rng() * 2.2;
p.alpha = 0.4 + rng() * 0.45;
}
function initParticles() {
particles = [];
for (var i = 0; i < COUNT; i++) { var p = {}; spawnAt(p, true); particles.push(p); }
}
var scrollPos = 0, scrollVel = 0, scrollDir = 0, dirSign = 1, 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; dirSign = scrollDir; idleMs = 0; }
else { scrollDir = 0; idleMs += dt; } // dirSign 은 마지막 방향을 유지한다 — 정지해도 결이 사라지지 않는다
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 step(dtSec) {
for (var i = 0; i < particles.length; i++) {
var p = particles[i];
p.y -= RISE_SPEED * dtSec;
p.x += laneSign(p.lane) * dirSign * LANE_SPEED * dtSec;
var laneMin = p.lane / LANES * W, laneMax = (p.lane + 1) / LANES * W;
if (p.x < laneMin - 6) p.x = laneMin - 6; else if (p.x > laneMax + 6) p.x = laneMax + 6;
if (p.y < -8) spawnAt(p, false);
}
}
function paint() {
ctx.clearRect(0, 0, W, H);
ctx.fillStyle = PARTICLE_COLOR;
for (var i = 0; i < particles.length; i++) {
var p = particles[i];
ctx.globalAlpha = p.alpha;
ctx.beginPath(); ctx.arc(p.x, p.y, p.r, 0, Math.PI * 2); ctx.fill();
}
ctx.globalAlpha = 1;
}
function updateVal() { val.textContent = dirSign >= 0 ? '↓' : '↑'; }
function paintStatic() {
ctx.clearRect(0, 0, W, H);
ctx.fillStyle = PARTICLE_COLOR;
for (var i = 0; i < particles.length; i++) {
var p = particles[i];
var laneMid = (p.lane + 0.5) / LANES * W;
var offset = laneSign(p.lane) * dirSign * scrollPos * (W / LANES) * 0.35;
ctx.globalAlpha = p.alpha;
ctx.beginPath(); ctx.arc(laneMid + offset, p.y, p.r, 0, Math.PI * 2); ctx.fill();
}
ctx.globalAlpha = 1;
updateVal();
}
var lastFrameT = 0;
function frame(now) {
if (!lastFrameT) lastFrameT = now;
var dtSec = Math.min(0.05, (now - lastFrameT) / 1000);
lastFrameT = now;
step(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();
initParticles();
if (reduce) {
root.classList.add('is-still');
sampleScroll(performance.now());
paintStatic();
} else {
requestAnimationFrame(frame);
requestAnimationFrame(driveTick);
}
})();// DirectionReverse — vanilla 판(run/370/demo/03-scroll-particle-direction-reverse/index.html) 의 React 포트.
// 계산 겹: 방향 부호장(direction-sign field) — 파티클은 레인마다 고정된 부호(lane sign)를 갖고,
// 실제 가로 속도는 그 부호에 스크롤 방향 부호를 곱한 값이다. 방향이 바뀌면 레인의 엇갈림이 통째로 뒤집힌다.
import { useEffect, useRef } from "react";
import styles from "./DirectionReverse.module.scss";
type Box<T> = { current: T };
interface Particle { lane: number; x: number; y: number; r: number; alpha: number; }
export default function DirectionReverse() {
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(7331);
const css = getComputedStyle(root);
const PARTICLE_COLOR = css.getPropertyValue("--particle-color").trim() || "#17141a";
const LANES = 5;
const LANE_SPEED = 46;
const RISE_SPEED = 20;
const COUNT = 40;
let W = 0, H = 0, dpr = 1;
let particles: Particle[] = [];
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);
}
function laneSign(lane: number) { return lane % 2 === 0 ? 1 : -1; }
function spawnAt(p: Particle, first: boolean) {
const lane = Math.floor(rng() * LANES);
p.lane = lane;
p.x = (lane + 0.5) / LANES * W + (rng() - 0.5) * (W / LANES) * 0.6;
p.y = first ? rng() * H : H + rng() * 20;
p.r = 1.4 + rng() * 2.2;
p.alpha = 0.4 + rng() * 0.45;
}
function initParticles() {
particles = [];
for (let i = 0; i < COUNT; i++) {
const p: Particle = { lane: 0, x: 0, y: 0, r: 0, alpha: 0 };
spawnAt(p, true);
particles.push(p);
}
}
let scrollPos = 0, scrollVel = 0, dirSign = 1, 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;
// dirSign 은 마지막 방향을 유지한다 — 정지해도 결이 사라지지 않는다
if (Math.abs(scrollVel) > 4) { dirSign = 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 step(dtSec: number) {
for (let i = 0; i < particles.length; i++) {
const p = particles[i];
p.y -= RISE_SPEED * dtSec;
p.x += laneSign(p.lane) * dirSign * LANE_SPEED * dtSec;
const laneMin = p.lane / LANES * W, laneMax = (p.lane + 1) / LANES * W;
if (p.x < laneMin - 6) p.x = laneMin - 6; else if (p.x > laneMax + 6) p.x = laneMax + 6;
if (p.y < -8) spawnAt(p, false);
}
}
function paint() {
ctx!.clearRect(0, 0, W, H);
ctx!.fillStyle = PARTICLE_COLOR;
for (let i = 0; i < particles.length; i++) {
const p = particles[i];
ctx!.globalAlpha = p.alpha;
ctx!.beginPath(); ctx!.arc(p.x, p.y, p.r, 0, Math.PI * 2); ctx!.fill();
}
ctx!.globalAlpha = 1;
}
function updateVal() { val!.textContent = dirSign >= 0 ? "↓" : "↑"; }
function paintStatic() {
ctx!.clearRect(0, 0, W, H);
ctx!.fillStyle = PARTICLE_COLOR;
for (let i = 0; i < particles.length; i++) {
const p = particles[i];
const laneMid = (p.lane + 0.5) / LANES * W;
const offset = laneSign(p.lane) * dirSign * scrollPos * (W / LANES) * 0.35;
ctx!.globalAlpha = p.alpha;
ctx!.beginPath(); ctx!.arc(laneMid + offset, p.y, p.r, 0, Math.PI * 2); ctx!.fill();
}
ctx!.globalAlpha = 1;
updateVal();
}
let lastFrameT = 0;
function frame(now: number) {
if (!lastFrameT) lastFrameT = now;
const dtSec = Math.min(0.05, (now - lastFrameT) / 1000);
lastFrameT = now;
step(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();
initParticles();
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">↓</b>
<div className={styles.track} aria-hidden="true" />
</div>
</div>
</main>
);
}03 · 方向反転 — スクロール方向の値が下から上に変わるとパーティクルの流れの方向の値も即座に逆になる方式 — 計測 累積面積 13.023% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
function step(dtSec) {
for (var i = 0; i < particles.length; i++) {
var p = particles[i];
p.y -= RISE_SPEED * dtSec;
p.x += laneSign(p.lane) * dirSign * LANE_SPEED * dtSec;
var laneMin = p.lane / LANES * W, laneMax = (p.lane + 1) / LANES * W;
if (p.x < laneMin - 6) p.x = laneMin - 6; else if (p.x > laneMax + 6) p.x = laneMax + 6;
if (p.y < -8) spawnAt(p, false);
}
}
数値が語ること
03番は累積面積の13.02%を平均強度101.9で占め、23フレーム中23フレームで動きます。方向反転が起きた瞬間、そのレーンの全パーティクルが一度に再描画されるためです——端に近いものだけではありません。
04奥行き遠近レイヤー — パーティクルごとに異なる奥行きの値を持ち、同じスクロール移動量が奥行きの値によって異なる距離で適用される方式
パーティクルごとに奥行きの値が異なるため、同じスクロール移動量でも奥行きの値が浅いパーティクルは大きく、深いパーティクルは小さく動きます。FOVと奥行きの割り算がその差を画面上へ投影し、DOLLY_RANGEがcameraZ()自身の移動量を決めるので、ページを少し動かすだけで奥行きの層全体が本物のカメラドリーのように動きます。

<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>スクロール位置がカメラの奥行きを押し込む値になります。</span>
</p>
<div class="sp__viewport" id="viewport" tabindex="0" role="region" aria-label="パーティクル奥行き スクロール領域">
<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 의 paint() 에 있다.
$duration: 2s;
$easing: $ease-out;
$color: $stage-ink;
@include stage($stage-paper);
.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-ink, .78);
font-size: 12px;
font-weight: 500;
line-height: 1.3;
}
.sp__ico {
width: 16px;
height: 16px;
flex: none;
color: $subject-blue-deep;
}
.sp__viewport {
position: relative;
width: 100%;
height: 176px;
overflow-y: auto;
overflow-x: hidden;
border-radius: $r-card;
background: rgba($stage-ink, .04);
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-ink, .78);
color: $subject-cream;
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: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.sp {
--particle-color: #17141a;
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(23, 20, 26, 0.78);
font-size: 12px;
font-weight: 500;
line-height: 1.3;
}
.sp__ico {
width: 16px;
height: 16px;
flex: none;
color: #0a4fe8;
}
.sp__viewport {
position: relative;
width: 100%;
height: 176px;
overflow-y: auto;
overflow-x: hidden;
border-radius: 16px;
background: rgba(23, 20, 26, 0.04);
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(23, 20, 26, 0.78);
color: #fff7e6;
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(15485863);
var css = getComputedStyle(root);
var PARTICLE_COLOR = css.getPropertyValue('--particle-color').trim() || '#17141a';
// 계산 겹: 원근 투영(perspective projection) + 화가 알고리즘(painter's algorithm) —
// 입자마다 3D 깊이(z)를 갖고, 스크롤 위치가 카메라 깊이(cameraZ)를 정한다.
// 화면 좌표는 FOV/(FOV+상대깊이) 배율로 투영하고, 매 프레임 깊이가 먼 것부터 가까운 순으로 다시 그린다.
var FOV = 130; // 시야 거리 상수 — 클수록 투영 왜곡이 완만하다
var DOLLY_RANGE = 520; // 스크롤 0→100% 가 카메라를 밀어 넣는 깊이 범위 (world 단위)
var Z_FAR = 560;
var NEAR = 6;
var COUNT = 70;
var W = 0, H = 0, dpr = 1;
var particles = [];
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);
}
function spawnAt(p, first) {
p.wx = (rng() - 0.5) * 260;
p.wy = (rng() - 0.5) * 160;
p.z = first ? rng() * Z_FAR : Z_FAR - 4 + rng() * 40;
p.r = 1.1 + rng() * 1.6;
}
function initParticles() {
particles = [];
for (var i = 0; i < COUNT; i++) { var p = {}; spawnAt(p, true); particles.push(p); }
}
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 cameraZ() { return scrollPos * DOLLY_RANGE; }
function project(p, camZ) {
var depth = p.z - camZ;
var scale = FOV / (FOV + Math.max(depth, NEAR));
return { sx: W / 2 + p.wx * scale, sy: H / 2 + p.wy * scale, scale: scale, depth: depth };
}
function step(dtSec) {
var camZ = cameraZ();
for (var i = 0; i < particles.length; i++) {
var p = particles[i];
if (p.z - camZ < NEAR) spawnAt(p, false); // 카메라를 지나친 입자는 가장 먼 자리로 재배치한다
}
}
function paint() {
var camZ = cameraZ();
ctx.clearRect(0, 0, W, H);
// 화가 알고리즘: 깊이가 먼 것부터 그린다 — 가까운(큰) 입자가 항상 먼 입자를 덮는다
var order = particles.slice().sort(function (a, b) { return (b.z - camZ) - (a.z - camZ); });
for (var i = 0; i < order.length; i++) {
var p = order[i];
var pr = project(p, camZ);
var a = Math.max(0, Math.min(1, pr.scale * 1.1));
ctx.globalAlpha = a;
ctx.fillStyle = PARTICLE_COLOR;
ctx.beginPath(); ctx.arc(pr.sx, pr.sy, Math.max(0.4, p.r * pr.scale * 5), 0, Math.PI * 2); ctx.fill();
}
ctx.globalAlpha = 1;
}
function updateVal() { val.textContent = Math.round(scrollPos * 100) + '%'; }
function paintStatic() {
var camZ = cameraZ();
ctx.clearRect(0, 0, W, H);
var order = particles.slice().sort(function (a, b) { return (b.z - camZ) - (a.z - camZ); });
for (var i = 0; i < order.length; i++) {
var p = order[i];
var pr = project(p, camZ);
var a = Math.max(0, Math.min(1, pr.scale * 1.1));
ctx.globalAlpha = a;
ctx.fillStyle = PARTICLE_COLOR;
ctx.beginPath(); ctx.arc(pr.sx, pr.sy, Math.max(0.4, p.r * pr.scale * 5), 0, Math.PI * 2); ctx.fill();
}
ctx.globalAlpha = 1;
updateVal();
}
var lastFrameT = 0;
function frame(now) {
if (!lastFrameT) lastFrameT = now;
var dtSec = Math.min(0.05, (now - lastFrameT) / 1000);
lastFrameT = now;
step(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();
initParticles();
if (reduce) {
root.classList.add('is-still');
sampleScroll(performance.now());
paintStatic();
} else {
requestAnimationFrame(frame);
requestAnimationFrame(driveTick);
}
})();// DepthParallax — vanilla 판(run/370/demo/04-scroll-particle-depth-parallax/index.html) 의 React 포트.
// 계산 겹: 원근 투영(perspective projection) + 화가 알고리즘(painter's algorithm) —
// 입자마다 3D 깊이(z)를 갖고, 스크롤 위치가 카메라 깊이(cameraZ)를 정한다.
// 화면 좌표는 FOV/(FOV+상대깊이) 배율로 투영하고, 매 프레임 깊이가 먼 것부터 가까운 순으로 다시 그린다.
import { useEffect, useRef } from "react";
import styles from "./DepthParallax.module.scss";
type Box<T> = { current: T };
interface Particle { wx: number; wy: number; z: number; r: number; }
export default function DepthParallax() {
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(15485863);
const css = getComputedStyle(root);
const PARTICLE_COLOR = css.getPropertyValue("--particle-color").trim() || "#17141a";
const FOV = 130;
const DOLLY_RANGE = 520;
const Z_FAR = 560;
const NEAR = 6;
const COUNT = 70;
let W = 0, H = 0, dpr = 1;
let particles: Particle[] = [];
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);
}
function spawnAt(p: Particle, first: boolean) {
p.wx = (rng() - 0.5) * 260;
p.wy = (rng() - 0.5) * 160;
p.z = first ? rng() * Z_FAR : Z_FAR - 4 + rng() * 40;
p.r = 1.1 + rng() * 1.6;
}
function initParticles() {
particles = [];
for (let i = 0; i < COUNT; i++) {
const p: Particle = { wx: 0, wy: 0, z: 0, r: 0 };
spawnAt(p, true);
particles.push(p);
}
}
let scrollPos = 0, scrollVel = 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) { 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 cameraZ() { return scrollPos * DOLLY_RANGE; }
function project(p: Particle, camZ: number) {
const depth = p.z - camZ;
const scale = FOV / (FOV + Math.max(depth, NEAR));
return { sx: W / 2 + p.wx * scale, sy: H / 2 + p.wy * scale, scale, depth };
}
function step(_dtSec: number) {
const camZ = cameraZ();
for (let i = 0; i < particles.length; i++) {
const p = particles[i];
if (p.z - camZ < NEAR) spawnAt(p, false);
}
}
function paint() {
const camZ = cameraZ();
ctx!.clearRect(0, 0, W, H);
const order = particles.slice().sort((a, b) => (b.z - camZ) - (a.z - camZ));
for (let i = 0; i < order.length; i++) {
const p = order[i];
const pr = project(p, camZ);
const a = Math.max(0, Math.min(1, pr.scale * 1.1));
ctx!.globalAlpha = a;
ctx!.fillStyle = PARTICLE_COLOR;
ctx!.beginPath(); ctx!.arc(pr.sx, pr.sy, Math.max(0.4, p.r * pr.scale * 5), 0, Math.PI * 2); ctx!.fill();
}
ctx!.globalAlpha = 1;
}
function updateVal() { val!.textContent = Math.round(scrollPos * 100) + "%"; }
function paintStatic() {
const camZ = cameraZ();
ctx!.clearRect(0, 0, W, H);
const order = particles.slice().sort((a, b) => (b.z - camZ) - (a.z - camZ));
for (let i = 0; i < order.length; i++) {
const p = order[i];
const pr = project(p, camZ);
const a = Math.max(0, Math.min(1, pr.scale * 1.1));
ctx!.globalAlpha = a;
ctx!.fillStyle = PARTICLE_COLOR;
ctx!.beginPath(); ctx!.arc(pr.sx, pr.sy, Math.max(0.4, p.r * pr.scale * 5), 0, Math.PI * 2); ctx!.fill();
}
ctx!.globalAlpha = 1;
updateVal();
}
let lastFrameT = 0;
function frame(now: number) {
if (!lastFrameT) lastFrameT = now;
const dtSec = Math.min(0.05, (now - lastFrameT) / 1000);
lastFrameT = now;
step(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();
initParticles();
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>
);
}04 · 奥行き遠近レイヤー — パーティクルごとに異なる奥行きの値を持ち、同じスクロール移動量が奥行きの値によって異なる距離で適用される方式 — 計測 累積面積 13.938% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
function cameraZ() { return scrollPos * DOLLY_RANGE; }
function project(p, camZ) {
var depth = p.z - camZ;
var scale = FOV / (FOV + Math.max(depth, NEAR));
return { sx: W / 2 + p.wx * scale, sy: H / 2 + p.wy * scale, scale: scale, depth: depth };
}
function step(dtSec) {
var camZ = cameraZ();
for (var i = 0; i < particles.length; i++) {
var p = particles[i];
if (p.z - camZ < NEAR) spawnAt(p, false); // カメラを通り過ぎた粒子は最も遠い位置へ再配置する
}
}
数値が語ること
04番は累積面積の13.94%を平均強度85.8で占め、23フレーム中23フレームで動きます。浅い奥行きのパーティクルほど投影された大きさの変化が広い面積を塗り直すため、同じワールド距離を動く深いパーティクルより面積が大きくなるからです。
05停止沈降 — スクロールが止まった瞬間、パーティクルが重力の値の影響を受けて徐々に下へ沈む方式
スクロールの値が止まるとパーティクルの上昇の流れの値が消え、代わりに重力の値が働き、画面が止まっている間パーティクルは少しずつ沈みます。STIFFNESSのばねがSETTLE_DAMPの値で減衰しながら静止値へ戻り、idleMsがスクロールの静けさが続いた時間を数えます。

<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>手を離すと、揺れていた格子が徐々に沈んでいきます。</span>
</p>
<div class="sp__viewport" id="viewport" tabindex="0" role="region" aria-label="パーティクル停止 スクロール領域">
<canvas class="sp__cv" id="cv" aria-hidden="true"></canvas>
<b class="sp__val" id="val" aria-hidden="true">0ms</b>
<div class="sp__track" aria-hidden="true"></div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 변형 3개는 이 위 세 변수만 바꾼다. 계산 겹(감쇠 스프링)은 index.html 의 step() 에 있다.
$duration: 2s;
$easing: $ease-out;
$color: $subject-mint;
@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($subject-cream, .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($subject-cream, .04);
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-ink, .48);
color: $subject-cream;
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: #4cd4a6;
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, 247, 230, 0.78);
font-size: 12px;
font-weight: 500;
line-height: 1.3;
}
.sp__ico {
width: 16px;
height: 16px;
flex: none;
color: #4cd4a6;
}
.sp__viewport {
position: relative;
width: 100%;
height: 176px;
overflow-y: auto;
overflow-x: hidden;
border-radius: 16px;
background: rgba(255, 247, 230, 0.04);
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(23, 20, 26, 0.48);
color: #fff7e6;
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(2718281);
var css = getComputedStyle(root);
var PARTICLE_COLOR = css.getPropertyValue('--particle-color').trim() || '#4cd4a6';
// 계산 겹: 감쇠 스프링(gravity-damping spring) — 격자의 각 점은 스크롤 속도를 목표값(target)으로 쫓는
// 개별 스프링(STIFFNESS·SETTLE_DAMP)을 가진다. 스크롤이 멈추면 목표가 0 으로 떨어지며 서서히 가라앉는다.
var COLS = 7, ROWS = 5;
var STIFFNESS = 70; // 스프링 강성 (1/s^2 꼴)
var SETTLE_DAMP = 22; // 감쇠율 — 값이 낮을수록 오래 흔들리다 가라앉는다 (변형 대상 상수)
var DRIVE_GAIN = 0.09; // 스크롤 속도(px/s) → 목표 변위(px) 변환 계수
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);
layoutDots();
}
function layoutDots() {
dots = [];
for (var row = 0; row < ROWS; row++) {
for (var col = 0; col < COLS; col++) {
dots.push({
rx: (col + 0.5) / COLS * W,
ry: (row + 0.5) / ROWS * H,
dispX: 0, velX: 0,
stiff: STIFFNESS * (0.75 + rng() * 0.5),
r: 2.4 + rng() * 1.4
});
}
}
}
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 step(dtSec) {
var target = scrollVel * DRIVE_GAIN;
for (var i = 0; i < dots.length; i++) {
var d = dots[i];
var acc = (target - d.dispX) * d.stiff - d.velX * SETTLE_DAMP;
d.velX += acc * dtSec;
d.dispX += d.velX * dtSec;
}
}
function paint() {
ctx.clearRect(0, 0, W, H);
ctx.fillStyle = PARTICLE_COLOR;
for (var i = 0; i < dots.length; i++) {
var d = dots[i];
var settle = Math.max(0, 1 - Math.min(1, idleMs / 900));
ctx.globalAlpha = 0.5 + Math.min(0.4, Math.abs(d.velX) * 0.01) + settle * 0.05;
ctx.beginPath(); ctx.arc(d.rx + d.dispX, d.ry, d.r, 0, Math.PI * 2); ctx.fill();
}
ctx.globalAlpha = 1;
}
function updateVal() { val.textContent = Math.min(999, Math.round(idleMs)) + 'ms'; }
function paintStatic() {
ctx.clearRect(0, 0, W, H);
ctx.fillStyle = PARTICLE_COLOR;
var settleDisp = scrollDir === 0 ? 0 : scrollPos * 6 * scrollDir;
for (var i = 0; i < dots.length; i++) {
var d = dots[i];
ctx.globalAlpha = 0.6;
ctx.beginPath(); ctx.arc(d.rx + settleDisp, d.ry, d.r, 0, Math.PI * 2); ctx.fill();
}
ctx.globalAlpha = 1;
updateVal();
}
var lastFrameT = 0;
function frame(now) {
if (!lastFrameT) lastFrameT = now;
var dtSec = Math.min(0.05, (now - lastFrameT) / 1000);
lastFrameT = now;
step(dtSec);
paint();
updateVal();
requestAnimationFrame(frame);
}
var DRIVE_PERIOD_MS = 2000;
var driveStart = 0;
function triangleHold(frac) {
// 절반은 세게 훑고 절반은 멈춰 있어야 "정지 가라앉음" 이 캡처 구간 안에서 보인다
if (frac < 0.32) return frac / 0.32;
if (frac < 0.5) return 1;
if (frac < 0.82) return 1 - (frac - 0.5) / 0.32;
return 0;
}
function driveTick(now) {
if (!driveStart) driveStart = now;
if (isDemo && !reduce) {
var frac = ((now - driveStart) % DRIVE_PERIOD_MS) / DRIVE_PERIOD_MS;
var w = triangleHold(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);
}
})();// IdleSettle — vanilla 판(run/370/demo/05-scroll-particle-idle-settle/index.html) 의 React 포트.
// 계산 겹: 감쇠 스프링(gravity-damping spring) — 격자의 각 점은 스크롤 속도를 목표값(target)으로 쫓는
// 개별 스프링(STIFFNESS·SETTLE_DAMP)을 가진다. 스크롤이 멈추면 목표가 0 으로 떨어지며 서서히 가라앉는다.
import { useEffect, useRef } from "react";
import styles from "./IdleSettle.module.scss";
type Box<T> = { current: T };
interface Dot { rx: number; ry: number; dispX: number; velX: number; stiff: number; r: number; }
export default function IdleSettle() {
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(2718281);
const css = getComputedStyle(root);
const PARTICLE_COLOR = css.getPropertyValue("--particle-color").trim() || "#4cd4a6";
const COLS = 7, ROWS = 5;
const STIFFNESS = 70;
const SETTLE_DAMP = 22;
const DRIVE_GAIN = 0.09;
let W = 0, H = 0, dpr = 1;
let dots: Dot[] = [];
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);
layoutDots();
}
function layoutDots() {
dots = [];
for (let row = 0; row < ROWS; row++) {
for (let col = 0; col < COLS; col++) {
dots.push({
rx: (col + 0.5) / COLS * W,
ry: (row + 0.5) / ROWS * H,
dispX: 0, velX: 0,
stiff: STIFFNESS * (0.75 + rng() * 0.5),
r: 2.4 + rng() * 1.4,
});
}
}
}
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 step(dtSec: number) {
const target = scrollVel * DRIVE_GAIN;
for (let i = 0; i < dots.length; i++) {
const d = dots[i];
const acc = (target - d.dispX) * d.stiff - d.velX * SETTLE_DAMP;
d.velX += acc * dtSec;
d.dispX += d.velX * dtSec;
}
}
function paint() {
ctx!.clearRect(0, 0, W, H);
ctx!.fillStyle = PARTICLE_COLOR;
for (let i = 0; i < dots.length; i++) {
const d = dots[i];
const settle = Math.max(0, 1 - Math.min(1, idleMs / 900));
ctx!.globalAlpha = 0.5 + Math.min(0.4, Math.abs(d.velX) * 0.01) + settle * 0.05;
ctx!.beginPath(); ctx!.arc(d.rx + d.dispX, d.ry, d.r, 0, Math.PI * 2); ctx!.fill();
}
ctx!.globalAlpha = 1;
}
function updateVal() { val!.textContent = Math.min(999, Math.round(idleMs)) + "ms"; }
function paintStatic() {
ctx!.clearRect(0, 0, W, H);
ctx!.fillStyle = PARTICLE_COLOR;
const settleDisp = scrollDir === 0 ? 0 : scrollPos * 6 * scrollDir;
for (let i = 0; i < dots.length; i++) {
const d = dots[i];
ctx!.globalAlpha = 0.6;
ctx!.beginPath(); ctx!.arc(d.rx + settleDisp, d.ry, d.r, 0, Math.PI * 2); ctx!.fill();
}
ctx!.globalAlpha = 1;
updateVal();
}
let lastFrameT = 0;
function frame(now: number) {
if (!lastFrameT) lastFrameT = now;
const dtSec = Math.min(0.05, (now - lastFrameT) / 1000);
lastFrameT = now;
step(dtSec);
paint();
updateVal();
rafFrameRef.current = requestAnimationFrame(frame);
}
const DRIVE_PERIOD_MS = 2000;
let driveStart = 0;
function triangleHold(frac: number) {
// 절반은 세게 훑고 절반은 멈춰 있어야 "정지 가라앉음" 이 캡처 구간 안에서 보인다
if (frac < 0.32) return frac / 0.32;
if (frac < 0.5) return 1;
if (frac < 0.82) return 1 - (frac - 0.5) / 0.32;
return 0;
}
function driveTick(now: number) {
if (!driveStart) driveStart = now;
if (isDemo && !reduce) {
const frac = ((now - driveStart) % DRIVE_PERIOD_MS) / DRIVE_PERIOD_MS;
const w = triangleHold(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">0ms</b>
<div className={styles.track} aria-hidden="true" />
</div>
</div>
</main>
);
}05 · 停止沈降 — スクロールが止まった瞬間、パーティクルが重力の値の影響を受けて徐々に下へ沈む方式 — 計測 累積面積 8.342% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
function step(dtSec) {
var target = scrollVel * DRIVE_GAIN;
for (var i = 0; i < dots.length; i++) {
var d = dots[i];
var acc = (target - d.dispX) * d.stiff - d.velX * SETTLE_DAMP;
d.velX += acc * dtSec;
d.dispX += d.velX * dtSec;
}
}
数値が語ること
05番は累積面積の8.34%を平均強度106.9で占め、23フレーム中23フレームで動きます。スクロールが止まった後もばねが短い間静止値へ戻り続けるため、最後のスクロールイベントより後まで動きが残るからです。
06フリックバースト — 短時間でスクロールの値が大きく跳ねると、その地点でパーティクルが瞬間的に飛び散る方式
短い時間でスクロールの値が大きく跳ねると、その位置でパーティクル数の値が瞬間的に増え四方へ散ります。スクロール速度の値がFLICK_THRESHOLDを下から上へ超えた立ち上がりでburst()が呼ばれ、その地点からBURST_COUNT(40個)のパーティクルが一度に散らばります。

<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>弾くように素早くスクロールすると、その瞬間パーティクルが放射状に弾けます。</span>
</p>
<div class="sp__viewport" id="viewport" tabindex="0" role="region" aria-label="パーティクル爆発 スクロール領域">
<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 의 burst()/step() 에 있다.
$duration: 2s;
$easing: $ease-out;
$color: $subject-blue-deep;
@include stage($stage-yellow);
.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-ink, .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-ink, .07);
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-ink, .5);
color: $subject-cream;
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: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.sp {
--particle-color: #0a4fe8;
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(23, 20, 26, 0.78);
font-size: 12px;
font-weight: 500;
line-height: 1.3;
}
.sp__ico {
width: 16px;
height: 16px;
flex: none;
color: #0a4fe8;
}
.sp__viewport {
position: relative;
width: 100%;
height: 176px;
overflow-y: auto;
overflow-x: hidden;
border-radius: 16px;
background: rgba(23, 20, 26, 0.07);
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(23, 20, 26, 0.5);
color: #fff7e6;
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(99194853);
var css = getComputedStyle(root);
var PARTICLE_COLOR = css.getPropertyValue('--particle-color').trim() || '#17141a';
// 계산 겹: 방사 폭발 + 마찰 감쇠(burst-radiate-with-friction-decay) —
// 스크롤 속도가 문턱(FLICK_THRESHOLD)을 아래에서 위로 넘는 순간(플릭)마다 BURST_COUNT 개를
// 중심에서 방사형으로 쏘고, 이후 매 프레임 속도를 지수적으로 줄여(FRICTION) 멈춰 세운다.
var FLICK_THRESHOLD = 260; // 이 속도(px/s) 를 아래→위로 넘는 순간에만 새 폭발이 터진다
var BURST_COUNT = 40; // 플릭 한 번이 만드는 입자 수 (변형 대상 상수)
var BASE_SPEED = 60; // 방사 기본 속력 (px/s)
var SPEED_VAR = 90; // 방사 속력 편차 (px/s)
var FRICTION = 1.5; // 속도 감쇠율 (1/s)
var LIFE_MS = 1300;
var MAX_PARTICLES = 220;
var W = 0, H = 0, dpr = 1;
var particles = [];
var burstCount = 0;
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);
}
var scrollPos = 0, scrollVel = 0, scrollDir = 0, idleMs = 0, lastTop = 0, lastT = 0, prevAbsVel = 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.4;
if (Math.abs(scrollVel) > 4) { scrollDir = scrollVel > 0 ? 1 : -1; idleMs = 0; } else { idleMs += dt; }
lastTop = top; lastT = now;
var absVel = Math.abs(scrollVel);
if (prevAbsVel < FLICK_THRESHOLD && absVel >= FLICK_THRESHOLD) burst(now);
prevAbsVel = absVel;
}
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 burst(now) {
burstCount++;
for (var i = 0; i < BURST_COUNT; i++) {
var ang = rng() * Math.PI * 2;
var speed = BASE_SPEED + rng() * SPEED_VAR;
particles.push({
x: W / 2, y: H / 2,
vx: Math.cos(ang) * speed,
vy: Math.sin(ang) * speed,
r: 1.2 + rng() * 2.2,
born: now
});
}
if (particles.length > MAX_PARTICLES) particles.splice(0, particles.length - MAX_PARTICLES);
}
function step(now, dtSec) {
var decay = Math.exp(-FRICTION * dtSec);
for (var i = particles.length - 1; i >= 0; i--) {
var p = particles[i];
p.vx *= decay; p.vy *= decay;
p.x += p.vx * dtSec;
p.y += p.vy * dtSec;
if (now - p.born > LIFE_MS) particles.splice(i, 1);
}
}
function paint(now) {
ctx.clearRect(0, 0, W, H);
ctx.fillStyle = PARTICLE_COLOR;
for (var i = 0; i < particles.length; i++) {
var p = particles[i];
var t = (now - p.born) / LIFE_MS;
ctx.globalAlpha = Math.max(0, 1 - t) * 0.9;
ctx.beginPath(); ctx.arc(p.x, p.y, p.r, 0, Math.PI * 2); ctx.fill();
}
ctx.globalAlpha = 1;
}
function updateVal() { val.textContent = String(burstCount); }
function paintStatic() {
ctx.clearRect(0, 0, W, H);
ctx.fillStyle = PARTICLE_COLOR;
var n = 14;
var rad = 10 + scrollPos * 50;
for (var i = 0; i < n; i++) {
var ang = (i / n) * Math.PI * 2;
var x = W / 2 + Math.cos(ang) * rad;
var y = H / 2 + Math.sin(ang) * rad;
ctx.globalAlpha = 0.65;
ctx.beginPath(); ctx.arc(x, y, 2, 0, Math.PI * 2); ctx.fill();
}
ctx.globalAlpha = 1;
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(now);
updateVal();
requestAnimationFrame(frame);
}
var DRIVE_PERIOD_MS = 2000;
var driveStart = 0;
function flickWave(frac) {
// 급상승·급하강(플릭) 세 번과 그 사이 정지 유지를 반복하는 구동 파형 —
// 정지 구간마다 직전 폭발이 마찰로 가라앉는 모습을 보여준다
if (frac < 0.06) return (frac / 0.06) * 0.35;
if (frac < 0.2) return 0.35;
if (frac < 0.26) return 0.35 + (frac - 0.2) / 0.06 * 0.65;
if (frac < 0.5) return 1;
if (frac < 0.56) return 1 - (frac - 0.5) / 0.06 * 0.6;
if (frac < 0.8) return 0.4;
return 0.4 * (1 - (frac - 0.8) / 0.2);
}
function driveTick(now) {
if (!driveStart) driveStart = now;
if (isDemo && !reduce) {
var frac = ((now - driveStart) % DRIVE_PERIOD_MS) / DRIVE_PERIOD_MS;
var w = flickWave(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);
}
})();// BurstFlick — vanilla 판(run/370/demo/06-scroll-particle-burst-flick/index.html) 의 React 포트.
// 계산 겹: 방사 폭발 + 마찰 감쇠(burst-radiate-with-friction-decay) —
// 스크롤 속도가 문턱(FLICK_THRESHOLD)을 아래에서 위로 넘는 순간(플릭)마다 BURST_COUNT 개를
// 중심에서 방사형으로 쏘고, 이후 매 프레임 속도를 지수적으로 줄여(FRICTION) 멈춰 세운다.
import { useEffect, useRef } from "react";
import styles from "./BurstFlick.module.scss";
type Box<T> = { current: T };
interface Particle { x: number; y: number; vx: number; vy: number; r: number; born: number; }
export default function BurstFlick() {
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(99194853);
const css = getComputedStyle(root);
const PARTICLE_COLOR = css.getPropertyValue("--particle-color").trim() || "#17141a";
const FLICK_THRESHOLD = 260;
const BURST_COUNT = 40;
const BASE_SPEED = 60;
const SPEED_VAR = 90;
const FRICTION = 1.5;
const LIFE_MS = 1300;
const MAX_PARTICLES = 220;
let W = 0, H = 0, dpr = 1;
let particles: Particle[] = [];
let burstCount = 0;
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);
}
function burst(now: number) {
burstCount++;
for (let i = 0; i < BURST_COUNT; i++) {
const ang = rng() * Math.PI * 2;
const speed = BASE_SPEED + rng() * SPEED_VAR;
particles.push({
x: W / 2, y: H / 2,
vx: Math.cos(ang) * speed,
vy: Math.sin(ang) * speed,
r: 1.2 + rng() * 2.2,
born: now,
});
}
if (particles.length > MAX_PARTICLES) particles.splice(0, particles.length - MAX_PARTICLES);
}
let scrollPos = 0, scrollVel = 0, scrollDir = 0, idleMs = 0, lastTop = 0, lastT = 0, prevAbsVel = 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.4;
if (Math.abs(scrollVel) > 4) { scrollDir = scrollVel > 0 ? 1 : -1; idleMs = 0; } else { idleMs += dt; }
lastTop = top; lastT = now;
const absVel = Math.abs(scrollVel);
if (prevAbsVel < FLICK_THRESHOLD && absVel >= FLICK_THRESHOLD) burst(now);
prevAbsVel = absVel;
}
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 step(now: number, dtSec: number) {
const decay = Math.exp(-FRICTION * dtSec);
for (let i = particles.length - 1; i >= 0; i--) {
const p = particles[i];
p.vx *= decay; p.vy *= decay;
p.x += p.vx * dtSec;
p.y += p.vy * dtSec;
if (now - p.born > LIFE_MS) particles.splice(i, 1);
}
}
function paint(now: number) {
ctx!.clearRect(0, 0, W, H);
ctx!.fillStyle = PARTICLE_COLOR;
for (let i = 0; i < particles.length; i++) {
const p = particles[i];
const t = (now - p.born) / LIFE_MS;
ctx!.globalAlpha = Math.max(0, 1 - t) * 0.9;
ctx!.beginPath(); ctx!.arc(p.x, p.y, p.r, 0, Math.PI * 2); ctx!.fill();
}
ctx!.globalAlpha = 1;
}
function updateVal() { val!.textContent = String(burstCount); }
function paintStatic() {
ctx!.clearRect(0, 0, W, H);
ctx!.fillStyle = PARTICLE_COLOR;
const n = 14;
const rad = 10 + scrollPos * 50;
for (let i = 0; i < n; i++) {
const ang = (i / n) * Math.PI * 2;
const x = W / 2 + Math.cos(ang) * rad;
const y = H / 2 + Math.sin(ang) * rad;
ctx!.globalAlpha = 0.65;
ctx!.beginPath(); ctx!.arc(x, y, 2, 0, Math.PI * 2); ctx!.fill();
}
ctx!.globalAlpha = 1;
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(now);
updateVal();
rafFrameRef.current = requestAnimationFrame(frame);
}
const DRIVE_PERIOD_MS = 2000;
let driveStart = 0;
function flickWave(frac: number) {
// 급상승·급하강(플릭) 세 번과 그 사이 정지 유지를 반복하는 구동 파형 —
// 정지 구간마다 직전 폭발이 마찰로 가라앉는 모습을 보여준다
if (frac < 0.06) return (frac / 0.06) * 0.35;
if (frac < 0.2) return 0.35;
if (frac < 0.26) return 0.35 + (frac - 0.2) / 0.06 * 0.65;
if (frac < 0.5) return 1;
if (frac < 0.56) return 1 - (frac - 0.5) / 0.06 * 0.6;
if (frac < 0.8) return 0.4;
return 0.4 * (1 - (frac - 0.8) / 0.2);
}
function driveTick(now: number) {
if (!driveStart) driveStart = now;
if (isDemo && !reduce) {
const frac = ((now - driveStart) % DRIVE_PERIOD_MS) / DRIVE_PERIOD_MS;
const w = flickWave(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>
);
}06 · フリックバースト — 短時間でスクロールの値が大きく跳ねると、その地点でパーティクルが瞬間的に飛び散る方式 — 計測 累積面積 3.719% · 動いたフレーム 12/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
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.4;
if (Math.abs(scrollVel) > 4) { scrollDir = scrollVel > 0 ? 1 : -1; idleMs = 0; } else { idleMs += dt; }
lastTop = top; lastT = now;
var absVel = Math.abs(scrollVel);
if (prevAbsVel < FLICK_THRESHOLD && absVel >= FLICK_THRESHOLD) burst(now);
prevAbsVel = absVel;
}
数値が語ること
06番は累積面積のわずか3.72%を平均強度69.5で占め、23フレーム中12フレームでしか動きません——9つの中で最も低い累積面積、最も少ない動いたフレーム数です。バーストはFLICK_THRESHOLDを実際に超えている立ち上がりの瞬間しか描画されず、それ以外の時間は場が静止しているからです。
07区間別色相転換 — スクロール進行率の値が区間を超えるたびにパーティクルの色の値が次の区間の色に変わる方式
スクロール進行率の値が定めた区間を超えるたびに、パーティクル全体の色の値がその区間に割り当てられた色へ滑らかに変わります。bandColor()が3色の帯を補間し、BOB_FREQとBOB_AMPがパーティクルごとの小さな上下運動を加えるので、色が変わらない間も場が完全には静止しません。

<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>スクロール位置が色帯を横方向に押し流します。</span>
</p>
<div class="sp__viewport" id="viewport" tabindex="0" role="region" aria-label="パーティクル色帯 スクロール領域">
<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 의 bandColor() 에 있다.
$duration: 2s;
$easing: $ease-out;
$color: $subject-lilac;
@include stage($stage-orange);
.sp {
--band-a: #{$subject-pink};
--band-b: #{$subject-lilac};
--band-c: #{$subject-sky};
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-ink, .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-ink, .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-ink, .5);
color: $subject-cream;
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: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.sp {
--band-a: #ff9ebb;
--band-b: #b9a0ff;
--band-c: #6ec8ff;
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(23, 20, 26, 0.78);
font-size: 12px;
font-weight: 500;
line-height: 1.3;
}
.sp__ico {
width: 16px;
height: 16px;
flex: none;
color: #b9a0ff;
}
.sp__viewport {
position: relative;
width: 100%;
height: 176px;
overflow-y: auto;
overflow-x: hidden;
border-radius: 16px;
background: rgba(23, 20, 26, 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(23, 20, 26, 0.5);
color: #fff7e6;
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(577215);
var css = getComputedStyle(root);
function readHex(name, fallback) {
var v = css.getPropertyValue(name).trim();
return v || fallback;
}
function parseHex(hex) {
hex = hex.replace('#', '');
return {
r: parseInt(hex.substring(0, 2), 16),
g: parseInt(hex.substring(2, 4), 16),
b: parseInt(hex.substring(4, 6), 16)
};
}
var BAND_A = parseHex(readHex('--band-a', '#ff9ebb'));
var BAND_B = parseHex(readHex('--band-b', '#b9a0ff'));
var BAND_C = parseHex(readHex('--band-c', '#6ec8ff'));
// 계산 겹: 색 띠 보간(color-band interpolation) — 세 앵커 색을 자체 RGB lerp 로 잇는 띠를 만들고,
// 입자마다 자기 x 위치 + 스크롤 위치를 더한 값으로 띠 위 지점을 골라 그 색을 그린다.
function lerpRGB(c1, c2, t) {
return { r: c1.r + (c2.r - c1.r) * t, g: c1.g + (c2.g - c1.g) * t, b: c1.b + (c2.b - c1.b) * t };
}
function bandColor(t) {
t = ((t % 1) + 1) % 1;
var c = t < 0.5 ? lerpRGB(BAND_A, BAND_B, t * 2) : lerpRGB(BAND_B, BAND_C, (t - 0.5) * 2);
return 'rgb(' + (c.r | 0) + ',' + (c.g | 0) + ',' + (c.b | 0) + ')';
}
var BOB_FREQ = 1.6; // 초당 제자리 흔들림 주파수 (Hz)
var BOB_AMP = 9; // 흔들림 진폭 (px)
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);
layoutDots();
}
function layoutDots() {
dots = [];
var cols = 10, rows = 6;
for (var i = 0; i < COUNT; i++) {
var col = i % cols, row = Math.floor(i / cols) % rows;
dots.push({
bx: (col + 0.5) / cols * W,
by: (row + 0.5) / rows * H,
phase: rng() * Math.PI * 2,
r: 2 + rng() * 2.4
});
}
}
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 paint(now) {
ctx.clearRect(0, 0, W, H);
for (var i = 0; i < dots.length; i++) {
var d = dots[i];
var y = d.by + Math.sin(now / 1000 * BOB_FREQ * Math.PI * 2 + d.phase) * BOB_AMP;
var u = d.bx / W + scrollPos;
ctx.fillStyle = bandColor(u);
ctx.globalAlpha = 0.86;
ctx.beginPath(); ctx.arc(d.bx, y, d.r, 0, Math.PI * 2); ctx.fill();
}
ctx.globalAlpha = 1;
}
function updateVal() { val.textContent = Math.round(((scrollPos % 1 + 1) % 1) * 100) + '%'; }
function paintStatic() {
ctx.clearRect(0, 0, W, H);
for (var i = 0; i < dots.length; i++) {
var d = dots[i];
var u = d.bx / W + scrollPos;
ctx.fillStyle = bandColor(u);
ctx.globalAlpha = 0.86;
ctx.beginPath(); ctx.arc(d.bx, d.by, d.r, 0, Math.PI * 2); ctx.fill();
}
ctx.globalAlpha = 1;
updateVal();
}
function frame(now) {
paint(now);
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);
}
})();// HueShift — vanilla 판(run/370/demo/07-scroll-particle-hue-shift/index.html) 의 React 포트.
// 계산 겹: 색 띠 보간(color-band interpolation) — 직접 구현한 parseHex()/lerpRGB()/bandColor() 로
// 세 앵커 색을 잇는 띠를 만들고, 입자마다 자기 x 위치 + 스크롤 위치를 더한 값으로 띠 위 지점을 골라
// 그 색을 그린다. 여기에 자율 사인 바빙(bobbing)을 얹는다.
import { useEffect, useRef } from "react";
import styles from "./HueShift.module.scss";
type Box<T> = { current: T };
interface RGB { r: number; g: number; b: number; }
interface Dot { bx: number; by: number; phase: number; r: number; }
export default function HueShift() {
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(577215);
const css = getComputedStyle(root);
function readHex(name: string, fallback: string) {
const v = css.getPropertyValue(name).trim();
return v || fallback;
}
function parseHex(hex: string): RGB {
hex = hex.replace("#", "");
return {
r: parseInt(hex.substring(0, 2), 16),
g: parseInt(hex.substring(2, 4), 16),
b: parseInt(hex.substring(4, 6), 16),
};
}
const BAND_A = parseHex(readHex("--band-a", "#ff9ebb"));
const BAND_B = parseHex(readHex("--band-b", "#b9a0ff"));
const BAND_C = parseHex(readHex("--band-c", "#6ec8ff"));
function lerpRGB(c1: RGB, c2: RGB, t: number): RGB {
return { r: c1.r + (c2.r - c1.r) * t, g: c1.g + (c2.g - c1.g) * t, b: c1.b + (c2.b - c1.b) * t };
}
function bandColor(t: number) {
t = ((t % 1) + 1) % 1;
const c = t < 0.5 ? lerpRGB(BAND_A, BAND_B, t * 2) : lerpRGB(BAND_B, BAND_C, (t - 0.5) * 2);
return "rgb(" + (c.r | 0) + "," + (c.g | 0) + "," + (c.b | 0) + ")";
}
const BOB_FREQ = 1.6;
const BOB_AMP = 9;
const COUNT = 60;
let W = 0, H = 0, dpr = 1;
let dots: Dot[] = [];
function layoutDots() {
dots = [];
const cols = 10, rows = 6;
for (let i = 0; i < COUNT; i++) {
const col = i % cols, row = Math.floor(i / cols) % rows;
dots.push({
bx: (col + 0.5) / cols * W,
by: (row + 0.5) / rows * H,
phase: rng() * Math.PI * 2,
r: 2 + rng() * 2.4,
});
}
}
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);
layoutDots();
}
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 paint(now: number) {
ctx!.clearRect(0, 0, W, H);
for (let i = 0; i < dots.length; i++) {
const d = dots[i];
const y = d.by + Math.sin(now / 1000 * BOB_FREQ * Math.PI * 2 + d.phase) * BOB_AMP;
const u = d.bx / W + scrollPos;
ctx!.fillStyle = bandColor(u);
ctx!.globalAlpha = 0.86;
ctx!.beginPath(); ctx!.arc(d.bx, y, d.r, 0, Math.PI * 2); ctx!.fill();
}
ctx!.globalAlpha = 1;
}
function updateVal() { val!.textContent = Math.round(((scrollPos % 1 + 1) % 1) * 100) + "%"; }
function paintStatic() {
ctx!.clearRect(0, 0, W, H);
for (let i = 0; i < dots.length; i++) {
const d = dots[i];
const u = d.bx / W + scrollPos;
ctx!.fillStyle = bandColor(u);
ctx!.globalAlpha = 0.86;
ctx!.beginPath(); ctx!.arc(d.bx, d.by, d.r, 0, Math.PI * 2); ctx!.fill();
}
ctx!.globalAlpha = 1;
updateVal();
}
function frame(now: number) {
paint(now);
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>
);
}07 · 区間別色相転換 — スクロール進行率の値が区間を超えるたびにパーティクルの色の値が次の区間の色に変わる方式 — 計測 累積面積 7.906% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
function lerpRGB(c1, c2, t) {
return { r: c1.r + (c2.r - c1.r) * t, g: c1.g + (c2.g - c1.g) * t, b: c1.b + (c2.b - c1.b) * t };
}
function bandColor(t) {
t = ((t % 1) + 1) % 1;
var c = t < 0.5 ? lerpRGB(BAND_A, BAND_B, t * 2) : lerpRGB(BAND_B, BAND_C, (t - 0.5) * 2);
return 'rgb(' + (c.r | 0) + ',' + (c.g | 0) + ',' + (c.b | 0) + ')';
}
数値が語ること
07番は累積面積の7.91%を平均強度124.4で占め、23フレーム中23フレームで動きます。色の補間が位置の変化がわずかな時でも毎フレーム全パーティクルの塗り色を塗り直すからです。
08渦の吸引 — スクロール位置の値が特定の地点に近づくほど、その地点の渦の吸引力の値が大きくなる方式
スクロール位置の値が指定した地点に近づくほどその地点の渦の吸引力の値が大きくなり、パーティクルはより大きく渦を描いて引き込まれます。REST_SHRINKがスクロール位置が地点に近づくほど静止半径を縮め、PULL_RATEが実際の半径がその縮む目標へ近づく速さを決め、VORTEX_Kをその半径(下限8px)で割った値が引き込まれた後の回転の速さを決めます。

<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>スクロール位置が深いほど、渦は中心へ強く締め込まれます。</span>
</p>
<div class="sp__viewport" id="viewport" tabindex="0" role="region" aria-label="パーティクル渦 スクロール領域">
<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 의 step() 에 있다.
$duration: 2s;
$easing: $ease-out;
$color: $subject-blue-deep;
@include stage($stage-paper);
.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-ink, .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-ink, .05);
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-ink, .5);
color: $subject-cream;
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: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.sp {
--particle-color: #0a4fe8;
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(23, 20, 26, 0.78);
font-size: 12px;
font-weight: 500;
line-height: 1.3;
}
.sp__ico {
width: 16px;
height: 16px;
flex: none;
color: #0a4fe8;
}
.sp__viewport {
position: relative;
width: 100%;
height: 176px;
overflow-y: auto;
overflow-x: hidden;
border-radius: 16px;
background: rgba(23, 20, 26, 0.05);
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(23, 20, 26, 0.5);
color: #fff7e6;
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(31415926);
var css = getComputedStyle(root);
var PARTICLE_COLOR = css.getPropertyValue('--particle-color').trim() || '#0a4fe8';
// 계산 겹: 소용돌이 극좌표 벡터장(vortex polar vector field) — 각속도는 1/r 법칙(각속도 = VORTEX_K / r)을
// 따르는 진짜 소용돌이 장이라 중심에 가까울수록 빠르게 돈다. 스크롤 위치가 반지름의 목표값을 정해
// 깊이 끌수록 장이 조여들고(반지름↓ → 각속도↑), 손을 떼면 다시 풀린다.
var VORTEX_K = 3400; // 각속도 상수 (px·rad/s) — 각속도 = VORTEX_K / r
var PULL_RATE = 5.5; // 반지름이 목표값을 쫓는 완화율 (1/s)
var REST_SHRINK = 0.62; // 스크롤 100% 일 때 반지름이 줄어드는 비율
var COUNT = 48;
var W = 0, H = 0, dpr = 1, cx = 0, cy = 0, restR = 60;
var particles = [];
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);
cx = W / 2; cy = H / 2;
restR = Math.min(W, H) * 0.44;
if (particles.length === 0) initParticles();
}
function initParticles() {
particles = [];
for (var i = 0; i < COUNT; i++) {
particles.push({
theta: rng() * Math.PI * 2,
r: restR * (0.25 + rng() * 0.75),
size: 1.3 + rng() * 2.1
});
}
}
var scrollPos = 0, scrollVel = 0, scrollDir = 0, spinDir = 1, 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; spinDir = scrollDir; 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 step(dtSec) {
var target = restR * (1 - scrollPos * REST_SHRINK);
for (var i = 0; i < particles.length; i++) {
var p = particles[i];
p.r += (target - p.r) * PULL_RATE * dtSec;
var angVel = VORTEX_K / Math.max(p.r, 8);
p.theta += angVel * dtSec * 0.01 * spinDir;
}
}
function drawParticles() {
ctx.fillStyle = PARTICLE_COLOR;
for (var i = 0; i < particles.length; i++) {
var p = particles[i];
var x = cx + Math.cos(p.theta) * p.r;
var y = cy + Math.sin(p.theta) * p.r * 0.72;
var near = 1 - Math.min(1, p.r / restR);
ctx.globalAlpha = 0.4 + near * 0.5;
ctx.beginPath(); ctx.arc(x, y, p.size * (1 + near * 0.6), 0, Math.PI * 2); ctx.fill();
}
ctx.globalAlpha = 1;
}
function paint() {
ctx.clearRect(0, 0, W, H);
drawParticles();
}
function updateVal() { val.textContent = Math.round(scrollPos * 100) + '%'; }
function paintStatic() {
var target = restR * (1 - scrollPos * REST_SHRINK);
for (var i = 0; i < particles.length; i++) particles[i].r += (target - particles[i].r) * 0.3;
paint();
updateVal();
}
var lastFrameT = 0;
function frame(now) {
if (!lastFrameT) lastFrameT = now;
var dtSec = Math.min(0.05, (now - lastFrameT) / 1000);
lastFrameT = now;
step(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);
}
})();// VortexPull — vanilla 판(run/370/demo/08-scroll-particle-vortex-pull/index.html) 의 React 포트.
// 계산 겹: 소용돌이 극좌표 벡터장(vortex polar vector field) — 각속도는 1/r 법칙(각속도 = VORTEX_K / r)을
// 따르는 진짜 소용돌이 장이라 중심에 가까울수록 빠르게 돈다. 스크롤 위치가 반지름의 목표값을 정해
// 깊이 끌수록 장이 조여들고(반지름↓ → 각속도↑), 손을 떼면 다시 풀린다.
import { useEffect, useRef } from "react";
import styles from "./VortexPull.module.scss";
type Box<T> = { current: T };
interface Particle { theta: number; r: number; size: number; }
export default function VortexPull() {
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(31415926);
const css = getComputedStyle(root);
const PARTICLE_COLOR = css.getPropertyValue("--particle-color").trim() || "#0a4fe8";
const VORTEX_K = 3400;
const PULL_RATE = 5.5;
const REST_SHRINK = 0.62;
const COUNT = 48;
let W = 0, H = 0, dpr = 1, cx = 0, cy = 0, restR = 60;
let particles: Particle[] = [];
function initParticles() {
particles = [];
for (let i = 0; i < COUNT; i++) {
particles.push({
theta: rng() * Math.PI * 2,
r: restR * (0.25 + rng() * 0.75),
size: 1.3 + rng() * 2.1,
});
}
}
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);
cx = W / 2; cy = H / 2;
restR = Math.min(W, H) * 0.44;
if (particles.length === 0) initParticles();
}
let scrollPos = 0, scrollVel = 0, scrollDir = 0, spinDir = 1, 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; spinDir = scrollDir; 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 step(dtSec: number) {
const target = restR * (1 - scrollPos * REST_SHRINK);
for (let i = 0; i < particles.length; i++) {
const p = particles[i];
p.r += (target - p.r) * PULL_RATE * dtSec;
const angVel = VORTEX_K / Math.max(p.r, 8);
p.theta += angVel * dtSec * 0.01 * spinDir;
}
}
function drawParticles() {
ctx!.fillStyle = PARTICLE_COLOR;
for (let i = 0; i < particles.length; i++) {
const p = particles[i];
const x = cx + Math.cos(p.theta) * p.r;
const y = cy + Math.sin(p.theta) * p.r * 0.72;
const near = 1 - Math.min(1, p.r / restR);
ctx!.globalAlpha = 0.4 + near * 0.5;
ctx!.beginPath(); ctx!.arc(x, y, p.size * (1 + near * 0.6), 0, Math.PI * 2); ctx!.fill();
}
ctx!.globalAlpha = 1;
}
function paint() {
ctx!.clearRect(0, 0, W, H);
drawParticles();
}
function updateVal() { val!.textContent = Math.round(scrollPos * 100) + "%"; }
function paintStatic() {
const target = restR * (1 - scrollPos * REST_SHRINK);
for (let i = 0; i < particles.length; i++) particles[i].r += (target - particles[i].r) * 0.3;
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(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>
);
}08 · 渦の吸引 — スクロール位置の値が特定の地点に近づくほど、その地点の渦の吸引力の値が大きくなる方式 — 計測 累積面積 7.172% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
function step(dtSec) {
var target = restR * (1 - scrollPos * REST_SHRINK);
for (var i = 0; i < particles.length; i++) {
var p = particles[i];
p.r += (target - p.r) * PULL_RATE * dtSec;
var angVel = VORTEX_K / Math.max(p.r, 8);
p.theta += angVel * dtSec * 0.01 * spinDir;
}
}
数値が語ること
08番は累積面積の7.17%を平均強度104.2で占め、23フレーム中23フレームで動きます。渦自体の半径変化が全パーティクルに同時に影響する一方、回転運動だけならもっと狭い輪しか塗り直さないからです。
09ノイズフロー歪み — スクロールの値が2Dノイズ場のオフセットの値を押し流し、パーティクルの経路が滑らかに曲がる流れに変わる方式
スクロールの値が増えるほどノイズ場のオフセットの値が移動し、同じ場所のパーティクル経路も時間とともに滑らかに異なる方向へ曲がります。自作のvalueNoise()がNOISE_SCALEの大きさの場をSCROLL_WARPでスクロールの値だけ押し流して読み取るので、曲がり方は急な一粒ごとのジャンプではなく滑らかで連続的です。

<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>スクロール速度がフロー場の速さを、位置が波の位相を変えます。</span>
</p>
<div class="sp__viewport" id="viewport" tabindex="0" role="region" aria-label="パーティクルノイズフロー スクロール領域">
<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 · ノイズフロー歪み — スクロールの値が2Dノイズ場のオフセットの値を押し流し、パーティクルの経路が滑らかに曲がる流れに変わる方式 — 計測 累積面積 13.367% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
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;
}
数値が語ること
09番は累積面積の13.37%を占め、9つの中で最も高い平均強度125.8で、23フレーム中23フレームで動きます。ノイズ場がフレームごとにパーティクルごと少しずつ異なる方向の値を与え、まとまったパーティクルの塊を一斉に同じように動かすわけではないからです。
値が位置と色を変える基準 — 速度値・奥行き値・位置値
パーティクル 背景 フリーの素材でもたいてい一様な流れになりますが、この9つのうち3つは自分のパーティクルの性質をそれぞれ異なるスクロールの基準に結びつけています。02番の生成率はスクロール速度の値に直接応えます——SPAWN_GAINは今どれだけ速く動いているかだけを読み取り、どれだけ移動したかは見ないので、遅く一定にスクロールすれば一定に生成され、速いフリックならフリックが終わった瞬間に収まるバーストで生成されます。04番の奥行きレイヤーはスクロール距離の値に応えますが均一ではありません——FOVと奥行きの割り算が同じスクロール距離でもパーティクルの投影位置の値をどれだけ動かすかを変えるので、浅いパーティクルはキャンバスを横切るほど動き、深いパーティクルは同じスクロール量でもほとんど動きません。08番の渦の吸引はそれとは対照的にスクロール位置の値だけに応えます——REST_SHRINKは現在のスクロール位置がある地点にどれだけ近いかだけを気にするので、その地点まで素早くスクロールしても、ゆっくりスクロールして止まっても、同じ位置なら同じ吸引力になります。この3つの定数はどのシーンに置かれても自分の基準を離れません——02番の生成率は速度に、04番の投影は奥行きに、08番の吸引は位置に応え続け、周りの数値だけが変わります。
この速度・奥行き・位置の違いは、こうしたシーンがこのページを離れて実際のセクションで使われるときに最も意味を持ちます。スクロール トリガー アニメーション 9選 — GSAPなしのバニラJSではスクロールの閾値を一度超えた瞬間に一つのアニメーションを発火させるだけで、上の9つのシーンのようにフレームごとにスクロール速度や位置の値を読み続けるわけではありません。スクロール 慣性 css 9選 — 値が自ら収まっていく9つの計算パターンはスクロールが止まると一つのUIの値を静止値へ戻すだけで、ここの05番と同じ停止沈降の考え方を一つのパーティクル群全体ではなく一つの値だけに適用します。スクロールテリング 作り方 9選 — コピペOKのCSSはセクション間をスクロールが移動するたびにセクション全体のレイアウトの値を切り替えるだけで、この9つのシーンのように一つのパーティクルの性質を連続的に変えるわけではありません。zipはx3fyh6j2で一度開けば、この9つのシーン全部がまとめて使えるようになり、項目ごとに別のパスワードを探す必要はありません。
どこで壊れるか — 速いスクロール、タブ切り替え、reduced-motion
実際のスクロールはデモのループよりずっと乱れており、この9つのシーンではそれが3つの異なる場所で表れます。
FLICK_THRESHOLDを一度も下回らない速いスクロール
06番のsampleScroll()はburst()を立ち上がりのときだけ呼びます——scrollVelの絶対値が下からFLICK_THRESHOLDを超えた瞬間だけです。すでにその閾値を超えた状態で始まり、一度も下回らないまま続く速いスクロールは、最初に超えた瞬間だけ一度バーストして、それ以降は起きません。区間全体を速くスクロールし続けても、その最初の一発の後は不思議なほど静かに見えることがあります。
タブが隠れている間にシーンへ戻ったとき
どのシーンの自動再生もdriveTick()自身の三角波からviewport.scrollTopを動かしており、その波の位相はrequestAnimationFrameが渡す実際のタイムスタンプを使ったnow - driveStartで計算されます。タブが隠れている間requestAnimationFrame自体が止まる一方で、波の位相は実時間のまま裏で進み続けるので、タブへ戻った瞬間に見える仮想スクロール位置は、シーンが視覚的に止まっていた場所ではなく、2秒周期のどこか別の位置になっている可能性があります。
reduced-motionでも読み込み時のスクロール位置がそのまま反映される
paintStatic()はスクロールの値を0だと仮定せず、まずsampleScroll()を一度呼んでから静止した配置を描くので、その静止フレームはページが読み込まれた時点のスクロール位置をそのまま反映します。prefers-reduced-motion: reduceを求める訪問者が長いページの途中で08番を開くと、渦はすでにそのスクロール位置が示す半径まで引き込まれた状態で見え、最も緩んだ静止状態の渦ではありません。
3つのバリエーションとアクセシビリティ
フロー速度を0.6→0.25に下げるとノイズ場のオフセットの値がゆっくり押し流され、パーティクルの経路が急に曲がらず緩やかに流れます。減衰を22→10に下げると、パーティクルが着地値で即停止せず少し行き過ぎてから戻るオーバーシュートが残り、沈降がより弾むように見えます。個数を40→90に増やすと、フリック時に飛び散るパーティクルの値がずっと密になり、画面が一瞬より密度高く見えます。
| バリエーション | 変わるもの | 変わらないもの |
|---|---|---|
| ノイズフローを緩やかに | FLOW_SPEED 0.6 → 0.25 |
valueNoise()の順列テーブル・NOISE_SCALE・スクロールからオフセットへの変換式そのもの |
| 沈降をより弾ませる | SETTLE_DAMP 22 → 10 |
STIFFNESS・idleMsのタイマー・どのパーティクルがどのばねに属するか |
| フリックバーストを密に | BURST_COUNT 40 → 90 |
FLICK_THRESHOLD・立ち上がりの検出方法・burst()自身の発生地点 |
9つとも自分の<canvas>にaria-hidden="true"を付けています。シーンは装飾であって情報そのものではないからです。そのうえで、今どの値が何をしているかを平易な言葉で説明する目に見えるヒントのspanを一緒に配置し、その説明をキャンバスのピクセルの中だけに閉じ込めません。各スクロールコンテナ自体もrole="region"と、中で何がスクロールしているかを示す自分のaria-labelを持つので、訪問者がキャンバスと関わる前にスクリーンリーダーがその領域を読み上げます。
FAQ
このスクロール パーティクル 背景 cssのセットは9つの別々のスクリプトですか、それとも一つの共通コンポーネントの設定違いですか
9つの独立したファイルです——各シーンが自分の定数と自分のstep関数を持ち、デモの上に示したコードフェンスは同じファイルから直接切り出した抜粋であって、一つのテンプレートに数値だけを差し替える共通の生成処理の出力ではありません。
09番の流れるノイズは本物のPerlinノイズのライブラリを使っていますか、それとも自作ですか
自作です——他のどのシーンとも同じ種を使う乱数生成器で一度だけシャッフルした256個の要素の順列テーブルを、hash2()とsmooth()の補間で連続的な場に変えており、外部のノイズライブラリもCDNも使っていません。
スクロールに反応するのに、なぜ各キャンバスにaria-hiddenを付けているのですか
キャンバス自体は、その横にある目に見えるヒントのテキストにすでに書かれている以上の情報を訪問者に与えないからです。キャンバスを支援技術から隠すことで、絶えず変わるピクセルの内容を読み上げ続けることを避け、代わりにヒントのspanが同じ内容を平易な言葉で伝えます。
Sources: MDN — Canvas API · MDN — CanvasRenderingContext2D · MDN — Window.requestAnimationFrame() · MDN — Document: scroll event









