05정지 가라앉음 — 스크롤이 멈추는 순간 파티클이 중력 값의 영향을 받아 서서히 아래로 가라앉는 방식
스크롤 값이 멈추면 파티클의 상승 흐름 값이 꺼지고 중력 값이 대신 작용해, 화면이 멈춘 동안 파티클이 조금씩 가라앉습니다.
쓰임읽기 화면의 배경 안정, 정적 섹션의 배경
STIFFNESSSETTLE_DAMPtriangleHold

<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 포함 · 스크롤 파티클 배경 9종 — 값 하나로 흐름이 달라지는 캔버스 장면
이 데모가 속한 글
스크롤 파티클 배경 9종 — 값 하나로 흐름이 달라지는 캔버스 장면파티클 배경 9종을 값 기준으로 나누면, 아홉 가지 중 하나는 스크롤 속도 값이 빨라질수록 파티클이 생성되는 빈도 값이 늘어나고, 하나는 파티클마다 다른 깊이 값에 따라 같은 스크롤 이동량이 서로 다른 거리로 적용되 이 글의 데모 전부 보기


