스크롤 파티클 배경 9종 — 값 하나로 흐름이 달라지는 캔버스 장면
파티클 배경 9종을 값 기준으로 나누면, 아홉 가지 중 하나는 스크롤 속도 값이 빨라질수록 파티클이 생성되는 빈도 값이 늘어나고, 하나는 파티클마다 다른 깊이 값에 따라 같은 스크롤 이동량이 서로 다른 거리로 적용되며, 또 하나는 스크롤 위치 값이 한 지점에 가까워질수록 그 자리
아홉 데모는 전부 스크롤 컨테이너의 scroll·resize 이벤트와 requestAnimationFrame 루프를 함께 써서, 스크롤 위치·속도·방향 값 중 하나를 매 프레임 계산해 파티클의 좌표나 색을 정합니다. 항목마다 그 값이 무엇을 밀어붙이는지 표와 코드로 먼저 짚고, 실제 스크롤 조작이 그 값을 어디서 깨뜨리는지는 뒤에서 따로 다룹니다.
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
자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip
- 01 기본 흐름장 — 스크롤 값이 없을 때도 파티클이 기준 속도 값으로 은은하게 위로 떠가는 바탕 흐름
- 02 속도 기반 생성 — 스크롤 속도 값이 빨라질수록 새로 생성되는 파티클의 빈도 값이 늘어나는 방식
- 03 방향 반전 — 스크롤 방향 값이 아래에서 위로 바뀌면 파티클 흐름의 방향 값도 즉시 반대로 바뀌는 방식
- 04 깊이 원근 레이어 — 파티클마다 다른 깊이 값을 가져, 같은 스크롤 이동량이 깊이 값에 따라 서로 다른 거리로 적용되는 방식
- 05 정지 가라앉음 — 스크롤이 멈추는 순간 파티클이 중력 값의 영향을 받아 서서히 아래로 가라앉는 방식
- 06 플릭 버스트 — 짧은 시간 안에 스크롤 값이 크게 뛰면 그 지점에서 파티클이 순간적으로 흩뿌려지는 방식
- 07 구간별 색상 전환 — 스크롤 진행률 값이 구간을 넘어설 때마다 파티클의 색상 값이 다음 구간 색으로 바뀌는 방식
- 08 소용돌이 흡인 — 스크롤 위치 값이 특정 지점에 가까워질수록 그 지점 소용돌이의 흡인력 값이 커지는 방식
- 09 노이즈 흐름 왜곡 — 스크롤 값이 2D 노이즈 필드의 오프셋 값을 밀어, 파티클 경로가 매끈하게 휘는 흐름으로 바뀌는 방식
아홉 개의 해부 — 어떤 값이 스크롤에 반응하는가
다음 표에는 캔버스를 얼마나 채웠는지·움직임이 얼마나 강했는지·몇 프레임이나 값이 바뀌었는지 세 값이 항목별로 들어 있습니다. 배경 파티클처럼 화면 전체를 채우는 항목 중 누적면적이 가장 넓은 항목은 01 하나(상승 흐름장이 화면 전체를 상시 채우는 만큼 면적을 크게 씁니다)이고, 가장 좁은 항목은 06 하나(평소엔 입자가 거의 없다가 플릭 순간에만 몰아서 터지므로 누적면적 자체가 작습니다)이며, 강도가 가장 센 항목은 09 하나(노이즈 필드가 매 프레임 각도를 다시 구해 순간 변화량이 큽니다)뿐입니다. 움직인프레임도 06만 12/23이고 나머지 여덟 항목은 전부 23/23입니다.
| 항목 | 누적면적% | 강도_평균 | 움직인프레임 |
|---|---|---|---|
| 01 | 15.71% | 88.9 | 23/23 |
| 02 | 8.29% | 85.1 | 23/23 |
| 03 | 13.02% | 101.9 | 23/23 |
| 04 | 13.94% | 85.8 | 23/23 |
| 05 | 8.34% | 106.9 | 23/23 |
| 06 | 3.72% | 69.5 | 12/23 |
| 07 | 7.91% | 124.4 | 23/23 |
| 08 | 7.17% | 104.2 | 23/23 |
| 09 | 13.37% | 125.8 | 23/23 |
아홉 항목이 값을 밀어붙이는 계산 방식으로 다시 나누면 네 무리로 갈립니다.
매 프레임 흐름장이 값을 미는 것 — 01·03·09
01은 RISE_SPEED·p.speedVar로 매 프레임 위로 올라가는 값 위에 스크롤 속도×WIND_GAIN 바람과 FIELD_FREQ 사인 흔들림을 더하고, 03은 레인마다 고정된 laneSign에 스크롤 방향 부호 dirSign을 곱해 가로 속도를 정하며, 09는 좌표에 scrollPos×SCROLL_WARP를 섞은 값을 PERM_SIZE 256 순열 기반 valueNoise에 넣어 매 프레임 흐름 각도를 다시 구합니다. 셋 다 목표 값을 따로 두지 않고 그 프레임의 필드 계산만으로 다음 위치가 곧바로 정해집니다.
스프링·완화로 목표를 뒤쫓는 값 — 05·08
05는 scrollVel×DRIVE_GAIN 목표 변위를 STIFFNESS·SETTLE_DAMP 스프링으로 뒤쫓고, 08은 restR×(1-scrollPos×REST_SHRINK) 목표 반지름을 PULL_RATE 완화율로 뒤쫓으며 각속도는 VORTEX_K/r 값으로 반지름이 작을수록 빨리 돕니다. 두 항목 모두 현재 값과 목표 값의 차이를 매 프레임 줄여 나가는 계산입니다.
스크롤 속도가 발생·소멸을 정하는 값 — 02·06
02는 |scrollVel|×SPAWN_GAIN으로 초당 생성률을 정해 파티클을 계속 흘려 내보내고, 06은 FLICK_THRESHOLD를 아래에서 위로 넘는 상승엣지에서만 BURST_COUNT개를 한 번에 터뜨립니다. 두 항목 다 생성된 뒤에는 FRICTION 값으로 지수 감쇠하다 LIFE_MS가 지나면 사라집니다.
위치·색이 스크롤 진행률에 직접 묶이는 값 — 04·07
04는 cameraZ를 scrollPos×DOLLY_RANGE로 정해 FOV/(FOV+depth) 투영 스케일이 스크롤을 따라 바뀌고, 07은 d.bx/W에 scrollPos를 더한 지점을 bandColor 띠에서 읽어 색을 정합니다. 둘 다 스크롤 진행률 값 자체가 계산의 입력입니다.
패턴별 코드
01기본 흐름장 — 스크롤 값이 없을 때도 파티클이 기준 속도 값으로 은은하게 위로 떠가는 바탕 흐름
스크롤 값이 0이어도 파티클은 RISE_SPEED·p.speedVar만큼 매 프레임 위로 올라가고, 스크롤 속도 값이 생기면 WIND_GAIN 배만큼 가로 바람 값이 더해집니다. 바람 위에는 FIELD_FREQ로 높이마다 다른 위상을 갖는 사인 흔들림(SWAY_AMP)이 겹쳐, 같은 높이라도 파티클마다 흐르는 방향이 조금씩 달라집니다.
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;
}
}

<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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
02속도 기반 생성 — 스크롤 속도 값이 빨라질수록 새로 생성되는 파티클의 빈도 값이 늘어나는 방식
sampleScroll이 매 scroll 이벤트마다 구한 |scrollVel| 값에 SPAWN_GAIN을 곱해 초당 생성률을 정하고, spawnAcc 누적값이 1을 넘을 때마다 파티클 하나를 스크롤 속도×LAUNCH_GAIN 방향으로 쏩니다. 생성된 파티클은 FRICTION 값으로 지수 감쇠하다 LIFE_MS가 지나면 배열에서 사라집니다.
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);
}
}

<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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
03방향 반전 — 스크롤 방향 값이 아래에서 위로 바뀌면 파티클 흐름의 방향 값도 즉시 반대로 바뀌는 방식
각 파티클은 레인마다 고정된 부호 laneSign을 갖고, 실제 가로 속도는 이 부호에 스크롤 방향 부호 dirSign을 곱한 값입니다. 스크롤 방향이 아래에서 위로 바뀌는 순간 dirSign이 반전돼 모든 레인의 엇갈림이 통째로 뒤집힙니다.
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);
}
}

<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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
04깊이 원근 레이어 — 파티클마다 다른 깊이 값을 가져, 같은 스크롤 이동량이 깊이 값에 따라 서로 다른 거리로 적용되는 방식
cameraZ 값이 scrollPos×DOLLY_RANGE로 정해지고, 각 파티클은 FOV/(FOV+depth) 스케일로 투영됩니다. paint는 카메라에서 먼 파티클부터 그리는 화가 알고리즘으로 겹침 순서를 맞추고, 카메라를 지나친 파티클은 NEAR 값 기준으로 다시 가장 먼 자리에 배치됩니다.
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); // 카메라를 지나친 입자는 가장 먼 자리로 재배치한다
}
}

<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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
05정지 가라앉음 — 스크롤이 멈추는 순간 파티클이 중력 값의 영향을 받아 서서히 아래로 가라앉는 방식
목표 변위 target은 scrollVel×DRIVE_GAIN이고, 각 점은 STIFFNESS·SETTLE_DAMP 스프링으로 그 목표를 매 프레임 뒤쫓습니다. 스크롤이 멈춰 목표 값이 0으로 수렴하면 점들이 가로로 제자리를 찾아가고, 그 사이 idleMs 값이 쌓이는 만큼 잔광 같은 투명도만 옅어집니다.
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;
}
}

<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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
06플릭 버스트 — 짧은 시간 안에 스크롤 값이 크게 뛰면 그 지점에서 파티클이 순간적으로 흩뿌려지는 방식
prevAbsVel과 FLICK_THRESHOLD를 매 scroll 이벤트마다 비교해, 속도 절대값이 문턱을 아래에서 위로 넘는 상승엣지에서만 burst()가 한 번 호출됩니다. burst는 BURST_COUNT개의 파티클을 중심에서 사방으로 발사해 FRICTION 값으로 감쇠시키고, LIFE_MS가 지나면 사라집니다.
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);
}

<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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
07구간별 색상 전환 — 스크롤 진행률 값이 구간을 넘어설 때마다 파티클의 색상 값이 다음 구간 색으로 바뀌는 방식
각 점의 색은 bandColor 함수가 BAND_A·BAND_B·BAND_C 세 앵커 색을 자체 lerpRGB로 이어 만든 띠에서, d.bx/W 값에 scrollPos를 더한 지점을 읽어 정합니다. 위치는 BOB_FREQ·BOB_AMP로 매 프레임 사인 흔들림만 더해질 뿐, 색을 정하는 값은 스크롤 진행률 하나입니다.
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) + ')';
}

<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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
08소용돌이 흡인 — 스크롤 위치 값이 특정 지점에 가까워질수록 그 지점 소용돌이의 흡인력 값이 커지는 방식
목표 반지름은 restR×(1-scrollPos×REST_SHRINK)로 정해지고, 각 파티클의 반지름 p.r은 PULL_RATE 완화율로 그 목표를 뒤쫓습니다. 각속도는 VORTEX_K/r 값(반지름이 작을수록 빨리 돎)으로 계산돼, spinDir 값에 따라 스크롤 방향으로 회전합니다.
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;
}
}

<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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
09노이즈 흐름 왜곡 — 스크롤 값이 2D 노이즈 필드의 오프셋 값을 밀어, 파티클 경로가 매끈하게 휘는 흐름으로 바뀌는 방식
fieldAngle은 좌표에 NOISE_SCALE·시간·scrollPos×SCROLL_WARP를 섞은 값을 PERM_SIZE 256 순열 기반 valueNoise(hash2·smooth 보간)에 넣어 각도로 바꿉니다. 각 점은 그 각도로 BASE_SPEED×FLOW_SPEED×speedGain만큼 이동해, 스크롤 속도가 빠를수록 흐름이 커지되 경로 자체는 노이즈 결을 따라 매끈하게 휩니다.
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;
}
}

<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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
값이 위치와 색을 바꾸는 곳 — 속도 값·깊이 값·위치 값 기준
아홉 항목을 다시 세 축으로 겹쳐 보면 각자 다른 값이 위치와 색을 정합니다. 속도 값이 기준인 쪽은 02(생성 빈도)·06(버스트 발생)처럼 |scrollVel|이 얼마나 큰지가 그 프레임에 파티클이 새로 생기는지를 정하고, 깊이 값이 기준인 쪽은 04 하나로 파티클마다 다른 z 값이 같은 스크롤 이동량을 서로 다른 화면 거리로 바꿉니다. 위치 값이 기준인 쪽은 07(색상 띠 좌표)·08(소용돌이 흡인력)처럼 scrollPos 자체가 그 프레임의 색이나 힘을 직접 정합니다.
나머지 01·03·05·09는 이 세 기준과 무관하게 자기만의 값을 씁니다. 01은 상승 흐름장의 바람 값, 03은 레인 방향 부호 값, 05는 스프링이 뒤쫓는 목표 변위 값, 09는 노이즈 필드의 오프셋 값이 각자 위치를 정하는 기준입니다.
어디서 깨지는가 — 빠른 스크롤, 탭 전환, reduced-motion
06은 스크롤 속도가 FLICK_THRESHOLD 근처에서 빠르게 오르내리면 상승엣지 판정이 짧은 간격으로 계속 참이 돼 burst()가 연달아 불리고, 그때마다 BURST_COUNT개가 쌓여 MAX_PARTICLES 220을 넘으면 splice가 가장 오래된 파티클부터 한꺼번에 잘라내 화면이 순간적으로 듬성해집니다.
02와 06은 파티클의 수명 계산이 born 시각과 현재 시각(now)의 실제 차이를 그대로 씁니다. 탭을 전환했다 돌아오면 requestAnimationFrame은 멈췄다 재개되지만 born 값은 실제 시간으로 남아 있어, 복귀한 첫 프레임에 now - p.born이 LIFE_MS를 한꺼번에 넘어 살아 있던 파티클 대부분이 그 프레임에 동시에 사라집니다.
파일을 열어 값을 직접 바꿔 보고 싶다면, 압축 비번 x3fyh6j2으로 아홉 항목의 html·scss·js 전체를 내려받을 수 있습니다.
reduced-motion에서는 아홉 항목 전부 paintStatic()으로 scrollPos 기반 정적 배치만 그립니다. 05는 스프링 계산 대신 scrollPos×6×scrollDir로 구한 고정 변위 하나로 점을 옮기고, 08은 반지름을 목표 반지름 쪽으로 고정 비율 0.3만큼 한 번 당겨 위치를 고정하며, 09는 fieldAngle을 시각 0으로 얼려 구한 각도로 점마다 6px씩만 옮긴 뒤 더는 움직이지 않습니다.
같은 스크롤 계산을 스크롤 트리거 애니메이션 9종 — GSAP 없이 바닐라 JS 엔진으로의 판정과 겹쳐 보면, 그 편은 임계값을 넘었는지 아닌지 온/오프 트리거 자체를 계산하고 이 편은 트리거 이후 캔버스 파티클의 좌표·색 값을 계산한다는 차이가 있습니다. 관성 스크롤 9종 — 멈춘 뒤에도 값이 스스로 줄어드는 아홉 가지 방식과 비교하면 그 편은 스크롤이 멈춘 뒤의 감쇠 자체가 주제이고, 이 편은 감쇠(02·06의 FRICTION)를 아홉 계산 축 중 하나로만 씁니다. 스크롤 스토리텔링 섹션 9종 — 복붙하는 CSS 장면 구성은 섹션 전체 레이아웃이 스크롤에 맞춰 바뀌는 쪽이라, 캔버스 안 점 하나하나의 값을 계산하는 이 편의 아홉 항목과는 계산 단위가 다릅니다.
변형 세 가지와 접근성
| 변형 | 값 | 느낌 |
|---|---|---|
| 노이즈 흐름 속도 늦추기 | 09 FLOW_SPEED 0.6 → 0.25 | 흐름 속도를 0.6→0.25로 낮추면 노이즈 필드의 오프셋 값이 천천히 밀려, 파티클 경로가 급하게 휘지 않고 완만하게 흐른다. |
| 정지 가라앉음 감쇠 낮추기 | 05 SETTLE_DAMP 22 → 10 | 감쇠를 22→10으로 낮추면 파티클이 목표 값에 바로 멈추지 않고 살짝 지나쳤다가 되돌아오는 오버슛이 남아, 가라앉음이 더 튕기듯 보인다. |
| 버스트 파티클 개수 늘리기 | 06 BURST_COUNT 40 → 90 | 개수를 40→90으로 늘리면 플릭 시 흩뿌려지는 파티클 값이 훨씬 촘촘해져 화면이 순간적으로 더 밀도 높게 보인다. |
canvas는 아홉 항목 모두 aria-hidden이라 스크린 리더가 그림을 직접 읽지 않고, 대신 뷰포트마다 role="region" aria-label로 무엇을 스크롤하는 영역인지 밝히고 그 옆 힌트 문장이 조작 방법을 글로 설명합니다. Canvas API는 픽셀을 그릴 뿐 접근성 트리에 아무 정보도 남기지 않으므로, 이런 텍스트 대체가 없으면 아홉 장면 모두 스크린 리더에는 빈 화면입니다. scroll 이벤트와 requestAnimationFrame은 reduced-motion 값이 켜지면 아홉 항목 전부 paintStatic()으로 대체돼, 파티클 연출 자체는 사라지지 않고 scrollPos 값만 반영한 정지 그림 하나로 남습니다.
FAQ
스크롤을 멈추면 파티클도 완전히 멈추나요?
아닙니다. 01은 스크롤 값이 0이어도 RISE_SPEED로 계속 위로 흐르고, 05는 목표 변위가 0에 가까워질 뿐 스프링 계산 자체는 계속 돌며, 07·09도 매 프레임 자기 필드 값을 다시 구합니다. 완전히 값이 고정되는 건 reduced-motion을 켰을 때뿐입니다.
zip 안 코드를 그대로 붙이면 css 파티클 배경이 바로 동작하나요?
네, 아홉 항목 모두 외부 라이브러리 없이 canvas·js 값 계산만으로 동작하도록 만들어졌습니다. zip 안 코드를 그대로 붙이면 각 항목의 step·paint 함수까지 포함해 그대로 동작합니다.
아홉 항목 중 계산이 가장 무거운 건 어떤 항목인가요?
04는 매 프레임 파티클 배열을 z 값 기준으로 다시 정렬하는 화가 알고리즘을 쓰고, 09는 파티클마다 valueNoise를 한 번(hash2 네 번) 계산합니다. 나머지 일곱 항목은 파티클마다 사칙연산 몇 줄만으로 다음 위치를 구합니다.









