スクロール 慣性 css 9選 — 値が自ら収まっていく9つの計算パターン
9つのうち1つはスクロールが止まった後も移動の値が直前の速度を引き継いで少しずつ小さくなり、1つは端を超えて伸びた値が離すと元の値へ弾むように戻り、もう1つは方向が変わった瞬間に値が逆に引かれてから元の値へ戻ります。「スクロール 慣性 css」で探している方に、9つの計算方式をそのまま見せます。
01 scroll-momentum-inertia-coast
02 scroll-momentum-elastic-overscroll-header
03 scroll-momentum-velocity-blur-image
04 scroll-momentum-spring-section-snap
05 scroll-momentum-decay-parallax-layers
06 scroll-momentum-gravity-settle-card
07 scroll-momentum-friction-marquee-drag
09 scroll-momentum-rubber-pull-nav
自動再生 · タイルを押すとその項目へ · すべて1つのzip
- 01 スクロール慣性コースティング — スクロールが止まった後も移動の値が直前の速度を引き継ぎ、少しずつ小さくなっていく方式
- 02 オーバースクロール弾性 — スクロールの端を超えると、伸びた値が戻る方式
- 03 スクロール速度ブラー — スクロール速度の値が大きいほど、ぼかしの値がそれに比例して大きくなる方式
- 04 区間スナップスプリング — 離されたスクロール位置の値が最も近い区間の値へ、スプリング減衰しながら落ち着く方式
- 05 レイヤー別減衰パララックス — スクロールが止まった後も、レイヤーごとに異なる減衰率で移動の値が縮み続ける方式
- 06 落下定着 — スクロールで表示されると要素の位置の値が重力の値で落下し、床の値で跳ねてから定着する方式
- 07 摩擦減速 — 入力の値が途切れた後、速度の値が摩擦の分だけ徐々に落ちていく方式
- 08 遅延追従 — 追従する値が実際のスクロールの値にすぐ追従せず、遅延の値だけ遅れて追いかける方式
- 09 ラバープル — スクロール方向が変わった瞬間、値がゴムのように引かれてから戻る方式
9つとも本物のスクロール入力にだけ反応し、それ以外には反応しません。8つ(01〜05、07〜09)は本物の wheel イベントとポインタードラッグ(pointerdown/pointermove/pointerup)の両方に応え、06だけは本物の scroll イベントに応えます — 06自身が持つ自動再生ループを訪問者のスクロールと取り違えないよう、専用の autoScrolling フラグで守られています。06・08・09は矢印キーの keydown にも応え、01と03はウィンドウの resize でスクロール可能な高さを測り直します。9つのどれも Math.random で動きを決めておらず、すべて requestAnimationFrame の中で一定の定数を読むだけの決定的な計算なので、同じスクロール入力なら何度でも同じ数値が出ます。ヒーローヘッダーや固定ナビにスクロール インタラクションらしい実在感を持たせたいとき、9つとも土台として使える動きです。
9つのパターンの解剖 — どの値がいつ変わるか
なぜ5つのパターンは力を積分せず、毎フレーム一定の比率で値を減衰させるのか
01・03・05・07・08は加速度の値を別に持たず、毎フレーム速度か位置の値を一定の比率定数で掛けるかイージングするだけです — vel = FRICTION(01)、vel = COAST_FRICTION(03)、レイヤーごとの vel *= L.decay(05)、vel += (BASE_SPEED - vel) * FRICTION_PULL(07)、thumbY += (target - thumbY) * LAG(08)という形で、値自身の直前の状態だけが次のフレームの入力になります。
なぜ3つのパターンはスプリングの復元力を毎フレーム速度へ、速度を位置へ積分するのか
02・04・09は、値が目標値からどれだけ離れているかから復元力を計算し(force = -SPRING_K * (x - target) - SPRING_C * v)、その力を速度へ足し、速度を値そのものへ足すという計算を毎フレーム繰り返します。だからこそ3つとも目標値を一度通り過ぎてから戻ってくることができ、02のヘッダーの伸び・04の区間位置・09のナビの拡大率は、どれも目標に収まる前に一度その先まで揺れます。
なぜ1つのパターンはスプリングの復元力ではなく、一定の重力加速度を積分するのか
06だけは位置に関係なく毎フレーム一定の値(GRAVITY)を速度へ足し続けます — 目標から離れるほど強く引かれるスプリングとは違い、落下する物体が一定の加速度で加速していくのと同じ計算です。位置の値が FLOOR_Y に届いたときだけ速度を反転させ RESTITUTION の分だけ縮めるので、一続きの落下が一度のスプリング振動ではなく跳ねながら収まる落下感に変わります。
パターン別コード
01スクロール慣性コースティング — スクロールが止まった後も移動の値が直前の速度を引き継ぎ、少しずつ小さくなっていく方式
指を離した瞬間の速度の値を引き継ぎ、移動の値が少しずつ小さくなって止まります。長いリストページや画像ギャラリーのスクロールでよく使われる方式で、指やホイールを離した直後に急に止まるのではなく、それまでと同じ方向へ動き続けるように見えるのが特徴です。

<main class="stage">
<div class="mic is-demo" id="mic">
<div class="mic__viewport" id="micViewport" role="application" aria-label="慣性スクロールリスト">
<ul class="mic__track" id="micTrack">
<li class="mic__row">項目01</li>
<li class="mic__row">項目02</li>
<li class="mic__row">項目03</li>
<li class="mic__row">項目04</li>
<li class="mic__row">項目05</li>
<li class="mic__row">項目06</li>
<li class="mic__row">項目07</li>
<li class="mic__row">項目08</li>
<li class="mic__row">項目09</li>
<li class="mic__row">項目10</li>
</ul>
</div>
<p class="mic__hint">ホイールやドラッグでスクロールして離すと、慣性で滑り続けます</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹(관성 코스팅 적분)은 JS 에 있다 — 여기는 정적 배치만.
@include stage($stage-ink);
.stage { position: relative; }
.mic {
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-3;
}
.mic__viewport {
position: relative;
width: 220px;
height: 150px;
overflow: hidden;
border-radius: $r-card;
background: rgba($stage-paper, .08);
border: 1px solid rgba($stage-paper, .18);
touch-action: none;
overscroll-behavior: contain;
cursor: grab;
}
.mic__track {
position: absolute;
left: 0;
top: 0;
width: 100%;
margin: 0;
padding: $sp-2;
list-style: none;
display: flex;
flex-direction: column;
gap: $sp-2;
will-change: transform;
}
.mic__row {
height: 34px;
flex: 0 0 auto;
display: flex;
align-items: center;
padding-left: $sp-3;
border-radius: $r-sm;
background: $subject-mint;
color: $subject-ink;
font-size: 13px;
font-weight: 600;
}
.mic__hint {
color: rgba($stage-paper, .72);
font-size: 12px;
text-align: center;
}
@media (max-width: 340px) {
.mic { gap: $sp-2; }
.mic__viewport { width: 168px; height: 82px; }
.mic__row { height: 26px; font-size: 12px; }
.mic__hint { font-size: 11px; }
}
@media (prefers-reduced-motion: reduce) {
.mic__track { transition: none !important; }
}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);
}
.stage {
position: relative;
}
.mic {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
}
.mic__viewport {
position: relative;
width: 220px;
height: 150px;
overflow: hidden;
border-radius: 16px;
background: rgba(255, 255, 255, 0.08);
border: 1px solid rgba(255, 255, 255, 0.18);
touch-action: none;
overscroll-behavior: contain;
cursor: grab;
}
.mic__track {
position: absolute;
left: 0;
top: 0;
width: 100%;
margin: 0;
padding: 8px;
list-style: none;
display: flex;
flex-direction: column;
gap: 8px;
will-change: transform;
}
.mic__row {
height: 34px;
flex: 0 0 auto;
display: flex;
align-items: center;
padding-left: 12px;
border-radius: 8px;
background: #4cd4a6;
color: #17141a;
font-size: 13px;
font-weight: 600;
}
.mic__hint {
color: rgba(255, 255, 255, 0.72);
font-size: 12px;
text-align: center;
}
@media (max-width: 340px) {
.mic {
gap: 8px;
}
.mic__viewport {
width: 168px;
height: 82px;
}
.mic__row {
height: 26px;
font-size: 12px;
}
.mic__hint {
font-size: 11px;
}
}
@media (prefers-reduced-motion: reduce) {
.mic__track {
transition: none !important;
}
}(function () {
var root = document.getElementById('mic');
var viewport = document.getElementById('micViewport');
var track = document.getElementById('micTrack');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
// 계산 겹: 손을 뗀 순간의 속도 값(vel)이 매 프레임 FRICTION 배씩 줄어들며 이동 값(pos)에
// 더해진다 — vel *= FRICTION; pos += vel. MIN_VELOCITY 밑으로 떨어지면 코스팅을 멈춘다.
var FRICTION = 0.93;
var MIN_VELOCITY = 0.04;
var WHEEL_SCALE = 0.55;
var pos = 0, vel = 0, raf = null, maxScroll = 0;
function measure() {
maxScroll = Math.max(0, track.scrollHeight - viewport.clientHeight);
}
function clamp(v) { return Math.max(-maxScroll, Math.min(0, v)); }
function paint() { track.style.transform = 'translateY(' + pos.toFixed(1) + 'px)'; }
function stopCoast() { if (raf !== null) { cancelAnimationFrame(raf); raf = null; } }
function coastTick() {
vel *= FRICTION;
pos = clamp(pos + vel);
if (pos === 0 || pos === -maxScroll) { vel = 0; }
paint();
if (Math.abs(vel) < MIN_VELOCITY) { raf = null; return; }
raf = requestAnimationFrame(coastTick);
}
function startCoast(v) {
vel = v;
if (reduce) { pos = clamp(pos + vel * 6); vel = 0; paint(); return; }
stopCoast();
raf = requestAnimationFrame(coastTick);
}
function wireReal() {
viewport.addEventListener('wheel', function (e) {
e.preventDefault();
root.classList.remove('is-demo'); stopAutoplay();
stopCoast();
pos = clamp(pos - e.deltaY * WHEEL_SCALE);
paint();
startCoast(-e.deltaY * WHEEL_SCALE * 0.4);
}, { passive: false });
var dragging = false, startY = 0, startPos = 0, lastY = 0, lastT = 0, dragVel = 0;
viewport.addEventListener('pointerdown', function (e) {
root.classList.remove('is-demo'); stopAutoplay(); stopCoast();
dragging = true; startY = lastY = e.clientY; startPos = pos; lastT = performance.now(); dragVel = 0;
viewport.setPointerCapture(e.pointerId);
});
viewport.addEventListener('pointermove', function (e) {
if (!dragging) { return; }
var t = performance.now();
var dt = Math.max(1, t - lastT);
dragVel = (e.clientY - lastY) / dt * 16.6;
pos = clamp(startPos + (e.clientY - startY));
paint();
lastY = e.clientY; lastT = t;
});
viewport.addEventListener('pointerup', function () {
if (!dragging) { return; }
dragging = false;
startCoast(dragVel);
});
}
wireReal();
measure();
window.addEventListener('resize', measure);
// 자동 루프: 번갈아 방향을 바꿔 세게 튕겨 같은 관성 코스팅 경로를 반복 재현한다.
var autoTimer = null, phase = 0;
function autoTick() {
measure();
if (maxScroll <= 0) { return; }
phase += 1;
var dir = (phase % 2 === 0) ? -1 : 1;
pos = clamp(dir > 0 ? 0 : -maxScroll);
paint();
startCoast(dir * -9.5);
}
function stopAutoplay() { if (autoTimer) { clearInterval(autoTimer); autoTimer = null; } }
if (!reduce) { autoTimer = setInterval(autoTick, 1500); autoTick(); }
})();import { useEffect, useRef } from "react";
import styles from "./ScrollMomentumInertiaCoast.module.scss";
export type ScrollMomentumInertiaCoastProps = {
color?: string;
};
export default function ScrollMomentumInertiaCoast({
color = "#4cd4a6",
}: ScrollMomentumInertiaCoastProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const root = rootRef.current;
if (!root) return;
const viewport = root.querySelector<HTMLDivElement>(`.${styles.viewport}`);
const track = root.querySelector<HTMLUListElement>(`.${styles.track}`);
if (!viewport || !track) return;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
// 계산 겹: 손을 뗀 순간의 속도 값(vel)이 매 프레임 FRICTION 배씩 줄어들며 이동 값(pos)에
// 더해진다 — vel *= FRICTION; pos += vel. MIN_VELOCITY 밑으로 떨어지면 코스팅을 멈춘다.
const FRICTION = 0.93;
const MIN_VELOCITY = 0.04;
const WHEEL_SCALE = 0.55;
let pos = 0, vel = 0, raf: number | null = null, maxScroll = 0;
function measure() {
maxScroll = Math.max(0, track!.scrollHeight - viewport!.clientHeight);
}
function clamp(v: number) { return Math.max(-maxScroll, Math.min(0, v)); }
function paint() { track!.style.transform = "translateY(" + pos.toFixed(1) + "px)"; }
function stopCoast() { if (raf !== null) { cancelAnimationFrame(raf); raf = null; } }
function coastTick() {
vel *= FRICTION;
pos = clamp(pos + vel);
if (pos === 0 || pos === -maxScroll) { vel = 0; }
paint();
if (Math.abs(vel) < MIN_VELOCITY) { raf = null; return; }
raf = requestAnimationFrame(coastTick);
}
function startCoast(v: number) {
vel = v;
if (reduce) { pos = clamp(pos + vel * 6); vel = 0; paint(); return; }
stopCoast();
raf = requestAnimationFrame(coastTick);
}
function onWheel(e: WheelEvent) {
e.preventDefault();
stopAutoplay();
stopCoast();
pos = clamp(pos - e.deltaY * WHEEL_SCALE);
paint();
startCoast(-e.deltaY * WHEEL_SCALE * 0.4);
}
let dragging = false, startY = 0, startPos = 0, lastY = 0, lastT = 0, dragVel = 0;
function onPointerDown(e: PointerEvent) {
stopAutoplay(); stopCoast();
dragging = true; startY = lastY = e.clientY; startPos = pos; lastT = performance.now(); dragVel = 0;
viewport!.setPointerCapture(e.pointerId);
}
function onPointerMove(e: PointerEvent) {
if (!dragging) return;
const t = performance.now();
const dt = Math.max(1, t - lastT);
dragVel = (e.clientY - lastY) / dt * 16.6;
pos = clamp(startPos + (e.clientY - startY));
paint();
lastY = e.clientY; lastT = t;
}
function onPointerUp() {
if (!dragging) return;
dragging = false;
startCoast(dragVel);
}
viewport.addEventListener("wheel", onWheel, { passive: false });
viewport.addEventListener("pointerdown", onPointerDown);
viewport.addEventListener("pointermove", onPointerMove);
viewport.addEventListener("pointerup", onPointerUp);
measure();
const onResize = () => measure();
window.addEventListener("resize", onResize);
let autoTimer: number | null = null, phase = 0;
function autoTick() {
measure();
if (maxScroll <= 0) return;
phase += 1;
const dir = phase % 2 === 0 ? -1 : 1;
pos = clamp(dir > 0 ? 0 : -maxScroll);
paint();
startCoast(dir * -9.5);
}
function stopAutoplay() { if (autoTimer !== null) { window.clearInterval(autoTimer); autoTimer = null; } }
if (!reduce) { autoTimer = window.setInterval(autoTick, 1500); autoTick(); }
return () => {
stopAutoplay();
stopCoast();
viewport.removeEventListener("wheel", onWheel);
viewport.removeEventListener("pointerdown", onPointerDown);
viewport.removeEventListener("pointermove", onPointerMove);
viewport.removeEventListener("pointerup", onPointerUp);
window.removeEventListener("resize", onResize);
};
}, []);
const rows = Array.from({ length: 10 }, (_, i) => i + 1);
return (
<div ref={rootRef} className={styles.stage}>
<div className={styles.mic}>
<div className={styles.viewport} role="application" aria-label="관성 스크롤 목록">
<ul className={styles.track}>
{rows.map((n) => (
<li key={n} className={styles.row} style={{ background: color }}>
항목 {String(n).padStart(2, "0")}
</li>
))}
</ul>
</div>
<p className={styles.hint}>휠이나 드래그로 스크롤한 뒤 놓으면 관성으로 미끄러집니다</p>
</div>
</div>
);
}01 · スクロール慣性コースティング — スクロールが止まった後も移動の値が直前の速度を引き継ぎ、少しずつ小さくなっていく方式 — 計測 累積面積 20.972% · 動いたフレーム 20/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
数値が示すこと
23枚の追跡フレームのうち01が動くのは20枚、累積面積は20.972%、平均強度は94.7です — この9つの中でも面積が大きい部類に入るのは、コースティングがトラック全体を滑らせる動きだからです。
var WHEEL_SCALE = 0.55;
var pos = 0, vel = 0, raf = null, maxScroll = 0;
function measure() {
maxScroll = Math.max(0, track.scrollHeight - viewport.clientHeight);
}
function clamp(v) { return Math.max(-maxScroll, Math.min(0, v)); }
function paint() { track.style.transform = 'translateY(' + pos.toFixed(1) + 'px)'; }
function stopCoast() { if (raf !== null) { cancelAnimationFrame(raf); raf = null; } }
function coastTick() {
vel *= FRICTION;
pos = clamp(pos + vel);
if (pos === 0 || pos === -maxScroll) { vel = 0; }
paint();
if (Math.abs(vel) < MIN_VELOCITY) { raf = null; return; }
raf = requestAnimationFrame(coastTick);
}
function startCoast(v) {
vel = v;
if (reduce) { pos = clamp(pos + vel * 6); vel = 0; paint(); return; }
stopCoast();
raf = requestAnimationFrame(coastTick);
}
02オーバースクロール弾性 — スクロールの端を超えると、伸びた値が戻る方式
端を超えて引くと伸びた値が、離すと元の値へ弾むように戻ります。モバイルの上部ヘッダーやアプリ風ランディングページでよく使われる方式で、指を離した瞬間にヘッダーが伸びてから戻る、オーバー スクロール バウンスらしい感触が価値になります。

<main class="stage">
<div class="eoh is-demo" id="eoh">
<div class="eoh__frame" id="eohFrame" role="application" aria-label="オーバースクロールヘッダー実験パネル">
<div class="eoh__header" id="eohHeader">ヘッダー</div>
<div class="eoh__body" id="eohBody">
<div class="eoh__row">通知01</div>
<div class="eoh__row">通知02</div>
<div class="eoh__row">通知03</div>
</div>
</div>
<p class="eoh__hint">上から下へ引っ張って離すと、ヘッダーが伸びてから戻ります</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹(저항·스프링 적분)은 JS 에 있다 — 여기는 정적 배치만.
@include stage($stage-yellow);
.stage { position: relative; }
.eoh {
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-3;
}
.eoh__frame {
position: relative;
width: 210px;
height: 168px;
overflow: hidden;
border-radius: $r-card;
background: $stage-paper;
box-shadow: $shadow-raised;
touch-action: none;
overscroll-behavior: contain;
cursor: grab;
}
.eoh__header {
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 56px;
transform-origin: top center;
display: flex;
align-items: flex-end;
justify-content: center;
padding-bottom: $sp-2;
background: $subject-lilac;
color: $subject-ink;
font-size: 14px;
font-weight: 700;
will-change: transform;
}
.eoh__body {
position: relative;
margin-top: 56px;
padding: $sp-2 $sp-3;
display: flex;
flex-direction: column;
gap: $sp-2;
will-change: transform;
}
.eoh__row {
height: 26px;
flex: 0 0 auto;
display: flex;
align-items: center;
padding-left: $sp-2;
border-radius: $r-sm;
background: rgba($subject-ink, .06);
color: $subject-ink;
font-size: 12px;
}
.eoh__hint {
color: rgba($subject-ink, .72);
font-size: 12px;
text-align: center;
}
@media (max-width: 340px) {
.eoh { gap: $sp-2; }
.eoh__frame { width: 160px; height: 96px; }
.eoh__header { height: 36px; font-size: 12px; }
.eoh__body { margin-top: 36px; }
.eoh__row { height: 18px; font-size: 11px; }
.eoh__hint { font-size: 11px; }
}
@media (prefers-reduced-motion: reduce) {
.eoh__header, .eoh__body { transition: none !important; }
}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);
}
.stage {
position: relative;
}
.eoh {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
}
.eoh__frame {
position: relative;
width: 210px;
height: 168px;
overflow: hidden;
border-radius: 16px;
background: #ffffff;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
touch-action: none;
overscroll-behavior: contain;
cursor: grab;
}
.eoh__header {
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 56px;
transform-origin: top center;
display: flex;
align-items: flex-end;
justify-content: center;
padding-bottom: 8px;
background: #b9a0ff;
color: #17141a;
font-size: 14px;
font-weight: 700;
will-change: transform;
}
.eoh__body {
position: relative;
margin-top: 56px;
padding: 8px 12px;
display: flex;
flex-direction: column;
gap: 8px;
will-change: transform;
}
.eoh__row {
height: 26px;
flex: 0 0 auto;
display: flex;
align-items: center;
padding-left: 8px;
border-radius: 8px;
background: rgba(23, 20, 26, 0.06);
color: #17141a;
font-size: 12px;
}
.eoh__hint {
color: rgba(23, 20, 26, 0.72);
font-size: 12px;
text-align: center;
}
@media (max-width: 340px) {
.eoh {
gap: 8px;
}
.eoh__frame {
width: 160px;
height: 96px;
}
.eoh__header {
height: 36px;
font-size: 12px;
}
.eoh__body {
margin-top: 36px;
}
.eoh__row {
height: 18px;
font-size: 11px;
}
.eoh__hint {
font-size: 11px;
}
}
@media (prefers-reduced-motion: reduce) {
.eoh__header, .eoh__body {
transition: none !important;
}
}(function () {
var root = document.getElementById('eoh');
var frame = document.getElementById('eohFrame');
var header = document.getElementById('eohHeader');
var body = document.getElementById('eohBody');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
// 계산 겹: 당긴 거리(dist)는 제곱근으로 저항을 걸어 늘어난 값(stretch)을 만든다(고무줄 저항).
// 손을 놓으면 stretch 를 목표 1 로 스프링 적분(SPRING_K·SPRING_C)해 되돌린다 — 속성 트윈이 아니라
// 매 프레임 가속도를 계산해 쌓는 진짜 스프링.
var PULL_RESIST = 9;
var MAX_STRETCH = 1.85;
var SPRING_K = 210;
var SPRING_C = 15;
var HEADER_H = 56;
var stretch = 1, sVel = 0, raf = null;
function paint() {
header.style.transform = 'scaleY(' + stretch.toFixed(3) + ')';
var extra = HEADER_H * (stretch - 1);
body.style.transform = 'translateY(' + extra.toFixed(1) + 'px)';
}
function stopSpring() { if (raf !== null) { cancelAnimationFrame(raf); raf = null; } }
function springTick() {
var force = -SPRING_K * (stretch - 1) - SPRING_C * sVel;
sVel += force * (1 / 60) * 0.06;
stretch += sVel;
paint();
if (Math.abs(stretch - 1) < 0.003 && Math.abs(sVel) < 0.003) {
stretch = 1; sVel = 0; paint(); raf = null; return;
}
raf = requestAnimationFrame(springTick);
}
function release() {
if (reduce) { stretch = 1; sVel = 0; paint(); return; }
stopSpring();
raf = requestAnimationFrame(springTick);
}
function pullTo(dist) {
var d = Math.max(0, dist);
stretch = Math.min(MAX_STRETCH, 1 + Math.sqrt(d) / PULL_RESIST);
paint();
}
function wireReal() {
var dragging = false, startY = 0;
frame.addEventListener('pointerdown', function (e) {
root.classList.remove('is-demo'); stopAutoplay(); stopSpring();
dragging = true; startY = e.clientY; sVel = 0;
frame.setPointerCapture(e.pointerId);
});
frame.addEventListener('pointermove', function (e) {
if (!dragging) { return; }
pullTo(e.clientY - startY);
});
frame.addEventListener('pointerup', function () {
if (!dragging) { return; }
dragging = false;
release();
});
frame.addEventListener('wheel', function (e) {
if (e.deltaY >= 0) { return; }
e.preventDefault();
root.classList.remove('is-demo'); stopAutoplay(); stopSpring();
pullTo((stretch - 1) * PULL_RESIST * PULL_RESIST + (-e.deltaY) * 1.4);
release();
}, { passive: false });
}
wireReal();
paint();
// 자동 루프: 당김 거리를 늘렸다가 놓기를 반복해 같은 저항→스프링 경로를 재현한다.
var autoTimer = null, phase = 0;
function autoTick() {
phase += 1;
var d = 58 + Math.sin(phase * 1.1) * 20;
pullTo(d);
setTimeout(function () { if (!root.classList.contains('is-demo')) { return; } release(); }, 260);
}
function stopAutoplay() { if (autoTimer) { clearInterval(autoTimer); autoTimer = null; } }
if (!reduce) { autoTimer = setInterval(autoTick, 900); autoTick(); }
})();import { useEffect, useRef } from "react";
import styles from "./ScrollMomentumElasticOverscrollHeader.module.scss";
export type ScrollMomentumElasticOverscrollHeaderProps = {
color?: string;
};
export default function ScrollMomentumElasticOverscrollHeader({
color = "#b9a0ff",
}: ScrollMomentumElasticOverscrollHeaderProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const root = rootRef.current;
if (!root) return;
const frame = root.querySelector<HTMLDivElement>(`.${styles.frame}`);
const header = root.querySelector<HTMLDivElement>(`.${styles.header}`);
const body = root.querySelector<HTMLDivElement>(`.${styles.body}`);
if (!frame || !header || !body) return;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
// 계산 겹: 당긴 거리(dist)는 제곱근으로 저항을 걸어 늘어난 값(stretch)을 만든다(고무줄 저항).
// 손을 놓으면 stretch 를 목표 1 로 스프링 적분(SPRING_K·SPRING_C)해 되돌린다.
const PULL_RESIST = 9;
const MAX_STRETCH = 1.85;
const SPRING_K = 210;
const SPRING_C = 15;
const HEADER_H = 56;
let stretch = 1, sVel = 0, raf: number | null = null;
function paint() {
header!.style.transform = "scaleY(" + stretch.toFixed(3) + ")";
const extra = HEADER_H * (stretch - 1);
body!.style.transform = "translateY(" + extra.toFixed(1) + "px)";
}
function stopSpring() { if (raf !== null) { cancelAnimationFrame(raf); raf = null; } }
function springTick() {
const force = -SPRING_K * (stretch - 1) - SPRING_C * sVel;
sVel += force * (1 / 60) * 0.06;
stretch += sVel;
paint();
if (Math.abs(stretch - 1) < 0.003 && Math.abs(sVel) < 0.003) {
stretch = 1; sVel = 0; paint(); raf = null; return;
}
raf = requestAnimationFrame(springTick);
}
function release() {
if (reduce) { stretch = 1; sVel = 0; paint(); return; }
stopSpring();
raf = requestAnimationFrame(springTick);
}
function pullTo(dist: number) {
const d = Math.max(0, dist);
stretch = Math.min(MAX_STRETCH, 1 + Math.sqrt(d) / PULL_RESIST);
paint();
}
let dragging = false, startY = 0;
function onPointerDown(e: PointerEvent) {
stopAutoplay(); stopSpring();
dragging = true; startY = e.clientY; sVel = 0;
frame!.setPointerCapture(e.pointerId);
}
function onPointerMove(e: PointerEvent) {
if (!dragging) return;
pullTo(e.clientY - startY);
}
function onPointerUp() {
if (!dragging) return;
dragging = false;
release();
}
function onWheel(e: WheelEvent) {
if (e.deltaY >= 0) return;
e.preventDefault();
stopAutoplay(); stopSpring();
pullTo((stretch - 1) * PULL_RESIST * PULL_RESIST + -e.deltaY * 1.4);
release();
}
frame.addEventListener("pointerdown", onPointerDown);
frame.addEventListener("pointermove", onPointerMove);
frame.addEventListener("pointerup", onPointerUp);
frame.addEventListener("wheel", onWheel, { passive: false });
paint();
let autoTimer: number | null = null, phase = 0;
let releaseTimeout: number | null = null;
function autoTick() {
phase += 1;
const d = 58 + Math.sin(phase * 1.1) * 20;
pullTo(d);
releaseTimeout = window.setTimeout(() => { release(); }, 260);
}
function stopAutoplay() {
if (autoTimer !== null) { window.clearInterval(autoTimer); autoTimer = null; }
if (releaseTimeout !== null) { window.clearTimeout(releaseTimeout); releaseTimeout = null; }
}
if (!reduce) { autoTimer = window.setInterval(autoTick, 900); autoTick(); }
return () => {
stopAutoplay();
stopSpring();
frame.removeEventListener("pointerdown", onPointerDown);
frame.removeEventListener("pointermove", onPointerMove);
frame.removeEventListener("pointerup", onPointerUp);
frame.removeEventListener("wheel", onWheel);
};
}, []);
return (
<div ref={rootRef} className={styles.stage}>
<div className={styles.eoh}>
<div className={styles.frame} role="application" aria-label="오버스크롤 헤더 실험판">
<div className={styles.header} style={{ background: color }}>헤더</div>
<div className={styles.body}>
<div className={styles.row}>알림 01</div>
<div className={styles.row}>알림 02</div>
<div className={styles.row}>알림 03</div>
</div>
</div>
<p className={styles.hint}>위에서 아래로 당겼다 놓으면 헤더가 늘어났다 되돌아옵니다</p>
</div>
</div>
);
}02 · オーバースクロール弾性 — スクロールの端を超えると、伸びた値が戻る方式 — 計測 累積面積 21.332% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
数値が示すこと
23枚の追跡フレームすべてで02は動き、累積面積21.332%、平均強度65.3です — 23枚全部でヘッダーが自分のオーバーシュートを戻し続けている数字です。
function springTick() {
var force = -SPRING_K * (stretch - 1) - SPRING_C * sVel;
sVel += force * (1 / 60) * 0.06;
stretch += sVel;
paint();
if (Math.abs(stretch - 1) < 0.003 && Math.abs(sVel) < 0.003) {
stretch = 1; sVel = 0; paint(); raf = null; return;
}
raf = requestAnimationFrame(springTick);
}
function release() {
if (reduce) { stretch = 1; sVel = 0; paint(); return; }
stopSpring();
raf = requestAnimationFrame(springTick);
}
function pullTo(dist) {
var d = Math.max(0, dist);
stretch = Math.min(MAX_STRETCH, 1 + Math.sqrt(d) / PULL_RESIST);
paint();
}
03スクロール速度ブラー — スクロール速度の値が大きいほど、ぼかしの値がそれに比例して大きくなる方式
速度の値が上がるほどぼかしの値が大きくなり、止まるとぼかしの値は0に戻ります。ギャラリーの背景画像やヒーローセクションの背景でよく使われる方式で、トラックを動かす同じ横方向の値がぼかしの強さも決めるため、速いフリックほど動きのぼやけとして見えます。

<main class="stage">
<div class="vbi is-demo" id="vbi">
<div class="vbi__viewport" id="vbiViewport" role="application" aria-label="速度ブラーギャラリー">
<div class="vbi__track" id="vbiTrack">
<div class="vbi__card vbi__card--a"></div>
<div class="vbi__card vbi__card--b"></div>
<div class="vbi__card vbi__card--c"></div>
<div class="vbi__card vbi__card--a"></div>
</div>
</div>
<p class="vbi__hint">横に速くスクロールするほどぼやけ、止まると再び鮮明になります</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹(속도→흐림 매핑)은 JS 에 있다 — 여기는 정적 배치만.
@include stage($stage-orange);
.stage { position: relative; }
.vbi {
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-3;
}
.vbi__viewport {
position: relative;
width: 230px;
height: 130px;
overflow: hidden;
border-radius: $r-card;
touch-action: none;
overscroll-behavior: contain;
cursor: grab;
}
.vbi__track {
position: absolute;
left: 0;
top: 0;
height: 100%;
display: flex;
gap: $sp-2;
will-change: transform, filter;
}
.vbi__card {
flex: 0 0 auto;
width: 140px;
height: 100%;
margin-top: $sp-2;
border-radius: $r-card;
&--a { background: linear-gradient(135deg, $subject-blue, $subject-lilac); }
&--b { background: linear-gradient(135deg, $subject-mint, $subject-sky); }
&--c { background: linear-gradient(135deg, $subject-pink, $subject-lilac); }
}
.vbi__hint {
color: rgba($subject-ink, .72);
font-size: 12px;
text-align: center;
}
@media (max-width: 340px) {
.vbi__viewport { width: 176px; height: 96px; }
.vbi__card { width: 108px; }
}
@media (prefers-reduced-motion: reduce) {
.vbi__track { transition: none !important; }
}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);
}
.stage {
position: relative;
}
.vbi {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
}
.vbi__viewport {
position: relative;
width: 230px;
height: 130px;
overflow: hidden;
border-radius: 16px;
touch-action: none;
overscroll-behavior: contain;
cursor: grab;
}
.vbi__track {
position: absolute;
left: 0;
top: 0;
height: 100%;
display: flex;
gap: 8px;
will-change: transform, filter;
}
.vbi__card {
flex: 0 0 auto;
width: 140px;
height: 100%;
margin-top: 8px;
border-radius: 16px;
}
.vbi__card--a {
background: linear-gradient(135deg, #2f6df6, #b9a0ff);
}
.vbi__card--b {
background: linear-gradient(135deg, #4cd4a6, #6ec8ff);
}
.vbi__card--c {
background: linear-gradient(135deg, #ff9ebb, #b9a0ff);
}
.vbi__hint {
color: rgba(23, 20, 26, 0.72);
font-size: 12px;
text-align: center;
}
@media (max-width: 340px) {
.vbi__viewport {
width: 176px;
height: 96px;
}
.vbi__card {
width: 108px;
}
}
@media (prefers-reduced-motion: reduce) {
.vbi__track {
transition: none !important;
}
}(function () {
var root = document.getElementById('vbi');
var viewport = document.getElementById('vbiViewport');
var track = document.getElementById('vbiTrack');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
// 계산 겹: 매 프레임의 이동 값 변화율(velocity)을 재서 흐림 목표 값(targetBlur = |velocity| *
// VELOCITY_TO_BLUR, MAX_BLUR 상한)을 만들고, 실제 표시 흐림(blur)은 BLUR_SMOOTH 비율로 그 목표를
// 뒤따른다 — 속도가 줄면 흐림도 뒤따라 0 으로 잦아든다. 코스팅 자체는 COAST_FRICTION 으로 감쇠.
var VELOCITY_TO_BLUR = 0.55;
var MAX_BLUR = 9;
var BLUR_SMOOTH = 0.25;
var COAST_FRICTION = 0.92;
var MIN_VELOCITY = 0.05;
var pos = 0, vel = 0, blur = 0, maxScroll = 0, raf = null;
function measure() { maxScroll = Math.max(0, track.scrollWidth - viewport.clientWidth); }
function clamp(v) { return Math.max(-maxScroll, Math.min(0, v)); }
function paint() {
track.style.transform = 'translateX(' + pos.toFixed(1) + 'px)';
track.style.filter = blur > 0.05 ? 'blur(' + blur.toFixed(2) + 'px)' : 'none';
}
function stopLoop() { if (raf !== null) { cancelAnimationFrame(raf); raf = null; } }
function loopTick() {
vel *= COAST_FRICTION;
pos = clamp(pos + vel);
if (pos === 0 || pos === -maxScroll) { vel = 0; }
var target = Math.min(MAX_BLUR, Math.abs(vel) * VELOCITY_TO_BLUR);
blur += (target - blur) * BLUR_SMOOTH;
paint();
if (Math.abs(vel) < MIN_VELOCITY && blur < 0.05) { blur = 0; paint(); raf = null; return; }
raf = requestAnimationFrame(loopTick);
}
function startLoop(v) {
vel = v;
if (reduce) { pos = clamp(pos + vel * 6); vel = 0; blur = 0; paint(); return; }
stopLoop();
raf = requestAnimationFrame(loopTick);
}
function wireReal() {
viewport.addEventListener('wheel', function (e) {
e.preventDefault();
root.classList.remove('is-demo'); stopAutoplay();
stopLoop();
var d = Math.abs(e.deltaX) > Math.abs(e.deltaY) ? e.deltaX : e.deltaY;
pos = clamp(pos - d * 0.6);
paint();
startLoop(-d * 0.6);
}, { passive: false });
var dragging = false, startX = 0, startPos = 0, lastX = 0, lastT = 0, dragVel = 0;
viewport.addEventListener('pointerdown', function (e) {
root.classList.remove('is-demo'); stopAutoplay(); stopLoop();
dragging = true; startX = lastX = e.clientX; startPos = pos; lastT = performance.now(); dragVel = 0;
viewport.setPointerCapture(e.pointerId);
});
viewport.addEventListener('pointermove', function (e) {
if (!dragging) { return; }
var t = performance.now();
var dt = Math.max(1, t - lastT);
dragVel = (e.clientX - lastX) / dt * 16.6;
pos = clamp(startPos + (e.clientX - startX));
var target = Math.min(MAX_BLUR, Math.abs(dragVel) * VELOCITY_TO_BLUR);
blur += (target - blur) * BLUR_SMOOTH;
paint();
lastX = e.clientX; lastT = t;
});
viewport.addEventListener('pointerup', function () {
if (!dragging) { return; }
dragging = false;
startLoop(dragVel);
});
}
wireReal();
measure();
window.addEventListener('resize', measure);
// 자동 루프: 번갈아 방향을 바꿔 세게 튕겨 속도→흐림 매핑을 반복 재현한다.
var autoTimer = null, phase = 0;
function autoTick() {
measure();
if (maxScroll <= 0) { return; }
phase += 1;
var dir = (phase % 2 === 0) ? -1 : 1;
pos = clamp(dir > 0 ? 0 : -maxScroll);
paint();
startLoop(dir * -13);
}
function stopAutoplay() { if (autoTimer) { clearInterval(autoTimer); autoTimer = null; } }
if (!reduce) { autoTimer = setInterval(autoTick, 1500); autoTick(); }
})();import { useEffect, useRef } from "react";
import styles from "./ScrollMomentumVelocityBlurImage.module.scss";
export type ScrollMomentumVelocityBlurImageProps = {
color?: string;
};
export default function ScrollMomentumVelocityBlurImage({
color = "#2f6df6",
}: ScrollMomentumVelocityBlurImageProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const root = rootRef.current;
if (!root) return;
const viewport = root.querySelector<HTMLDivElement>(`.${styles.viewport}`);
const track = root.querySelector<HTMLDivElement>(`.${styles.track}`);
if (!viewport || !track) return;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
// 계산 겹: 매 프레임의 이동 값 변화율(velocity)을 재서 흐림 목표 값(targetBlur = |velocity| *
// VELOCITY_TO_BLUR, MAX_BLUR 상한)을 만들고, 실제 표시 흐림(blur)은 BLUR_SMOOTH 비율로 그 목표를
// 뒤따른다 — 속도가 줄면 흐림도 뒤따라 0 으로 잦아든다. 코스팅 자체는 COAST_FRICTION 으로 감쇠.
const VELOCITY_TO_BLUR = 0.55;
const MAX_BLUR = 9;
const BLUR_SMOOTH = 0.25;
const COAST_FRICTION = 0.92;
const MIN_VELOCITY = 0.05;
let pos = 0, vel = 0, blur = 0, maxScroll = 0, raf: number | null = null;
function measure() { maxScroll = Math.max(0, track!.scrollWidth - viewport!.clientWidth); }
function clamp(v: number) { return Math.max(-maxScroll, Math.min(0, v)); }
function paint() {
track!.style.transform = "translateX(" + pos.toFixed(1) + "px)";
track!.style.filter = blur > 0.05 ? "blur(" + blur.toFixed(2) + "px)" : "none";
}
function stopLoop() { if (raf !== null) { cancelAnimationFrame(raf); raf = null; } }
function loopTick() {
vel *= COAST_FRICTION;
pos = clamp(pos + vel);
if (pos === 0 || pos === -maxScroll) { vel = 0; }
const target = Math.min(MAX_BLUR, Math.abs(vel) * VELOCITY_TO_BLUR);
blur += (target - blur) * BLUR_SMOOTH;
paint();
if (Math.abs(vel) < MIN_VELOCITY && blur < 0.05) { blur = 0; paint(); raf = null; return; }
raf = requestAnimationFrame(loopTick);
}
function startLoop(v: number) {
vel = v;
if (reduce) { pos = clamp(pos + vel * 6); vel = 0; blur = 0; paint(); return; }
stopLoop();
raf = requestAnimationFrame(loopTick);
}
function onWheel(e: WheelEvent) {
e.preventDefault();
stopAutoplay();
stopLoop();
const d = Math.abs(e.deltaX) > Math.abs(e.deltaY) ? e.deltaX : e.deltaY;
pos = clamp(pos - d * 0.6);
paint();
startLoop(-d * 0.6);
}
let dragging = false, startX = 0, startPos = 0, lastX = 0, lastT = 0, dragVel = 0;
function onPointerDown(e: PointerEvent) {
stopAutoplay(); stopLoop();
dragging = true; startX = lastX = e.clientX; startPos = pos; lastT = performance.now(); dragVel = 0;
viewport!.setPointerCapture(e.pointerId);
}
function onPointerMove(e: PointerEvent) {
if (!dragging) return;
const t = performance.now();
const dt = Math.max(1, t - lastT);
dragVel = (e.clientX - lastX) / dt * 16.6;
pos = clamp(startPos + (e.clientX - startX));
const target = Math.min(MAX_BLUR, Math.abs(dragVel) * VELOCITY_TO_BLUR);
blur += (target - blur) * BLUR_SMOOTH;
paint();
lastX = e.clientX; lastT = t;
}
function onPointerUp() {
if (!dragging) return;
dragging = false;
startLoop(dragVel);
}
viewport.addEventListener("wheel", onWheel, { passive: false });
viewport.addEventListener("pointerdown", onPointerDown);
viewport.addEventListener("pointermove", onPointerMove);
viewport.addEventListener("pointerup", onPointerUp);
measure();
const onResize = () => measure();
window.addEventListener("resize", onResize);
let autoTimer: number | null = null, phase = 0;
function autoTick() {
measure();
if (maxScroll <= 0) return;
phase += 1;
const dir = phase % 2 === 0 ? -1 : 1;
pos = clamp(dir > 0 ? 0 : -maxScroll);
paint();
startLoop(dir * -13);
}
function stopAutoplay() { if (autoTimer !== null) { window.clearInterval(autoTimer); autoTimer = null; } }
if (!reduce) { autoTimer = window.setInterval(autoTick, 1500); autoTick(); }
return () => {
stopAutoplay();
stopLoop();
viewport.removeEventListener("wheel", onWheel);
viewport.removeEventListener("pointerdown", onPointerDown);
viewport.removeEventListener("pointermove", onPointerMove);
viewport.removeEventListener("pointerup", onPointerUp);
window.removeEventListener("resize", onResize);
};
}, []);
return (
<div ref={rootRef} className={styles.stage}>
<div className={styles.vbi}>
<div className={styles.viewport} role="application" aria-label="속도 흐림 갤러리">
<div className={styles.track}>
<div className={`${styles.card} ${styles.cardA}`} style={{ background: `linear-gradient(135deg, ${color}, #b9a0ff)` }} />
<div className={`${styles.card} ${styles.cardB}`} />
<div className={`${styles.card} ${styles.cardC}`} />
<div className={`${styles.card} ${styles.cardA}`} style={{ background: `linear-gradient(135deg, ${color}, #b9a0ff)` }} />
</div>
</div>
<p className={styles.hint}>가로로 빠르게 스크롤할수록 흐려지고 멈추면 다시 선명해집니다</p>
</div>
</div>
);
}03 · スクロール速度ブラー — スクロール速度の値が大きいほど、ぼかしの値がそれに比例して大きくなる方式 — 計測 累積面積 20.219% · 動いたフレーム 18/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
数値が示すこと
23枚のうち03が動くのは18枚、累積面積20.219%、平均強度105.2です — 9つの中で平均強度が最も高いのは、位置とぼかしの両方が同時に動く場面が多いからです。
function paint() {
track.style.transform = 'translateX(' + pos.toFixed(1) + 'px)';
track.style.filter = blur > 0.05 ? 'blur(' + blur.toFixed(2) + 'px)' : 'none';
}
function stopLoop() { if (raf !== null) { cancelAnimationFrame(raf); raf = null; } }
function loopTick() {
vel *= COAST_FRICTION;
pos = clamp(pos + vel);
if (pos === 0 || pos === -maxScroll) { vel = 0; }
var target = Math.min(MAX_BLUR, Math.abs(vel) * VELOCITY_TO_BLUR);
blur += (target - blur) * BLUR_SMOOTH;
paint();
if (Math.abs(vel) < MIN_VELOCITY && blur < 0.05) { blur = 0; paint(); raf = null; return; }
raf = requestAnimationFrame(loopTick);
}
function startLoop(v) {
vel = v;
if (reduce) { pos = clamp(pos + vel * 6); vel = 0; blur = 0; paint(); return; }
stopLoop();
raf = requestAnimationFrame(loopTick);
}
04区間スナップスプリング — 離されたスクロール位置の値が最も近い区間の値へ、スプリング減衰しながら落ち着く方式
離された位置の値が近い区間の値の周りで揺れ、収まって止まります。フルページのセクション構成やスライド型の紹介ページでよく使われる方式で、途中のどこで指を離しても、中途半端な位置ではなく最も近い区間できれいに止まります。

<main class="stage">
<div class="sss is-demo" id="sss">
<div class="sss__viewport" id="sssViewport" role="application" aria-label="区間スナップスクロールパネル">
<div class="sss__track" id="sssTrack">
<section class="sss__panel sss__panel--1">区間1</section>
<section class="sss__panel sss__panel--2">区間2</section>
<section class="sss__panel sss__panel--3">区間3</section>
</div>
</div>
<p class="sss__hint">ドラッグしてスクロールし離すと、最も近い区間にスプリングで定着します</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹(스프링 적분)은 JS 에 있다 — 여기는 정적 배치만.
@include stage($stage-paper);
.stage { position: relative; }
.sss {
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-3;
}
.sss__viewport {
position: relative;
width: 200px;
height: 150px;
overflow: hidden;
border-radius: $r-card;
box-shadow: $shadow-raised;
touch-action: none;
overscroll-behavior: contain;
cursor: grab;
}
.sss__track {
position: absolute;
left: 0;
top: 0;
width: 100%;
will-change: transform;
}
.sss__panel {
width: 100%;
height: 150px;
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
font-weight: 700;
color: $subject-ink;
box-sizing: border-box;
&--1 { background: $subject-mint; }
&--2 { background: $subject-sky; }
&--3 { background: $subject-lilac; }
}
.sss__hint {
color: $subject-ink;
font-size: 12px;
text-align: center;
}
@media (max-width: 340px) {
.sss { gap: $sp-2; }
.sss__viewport { width: 152px; height: 86px; }
.sss__panel { height: 86px; font-size: 13px; }
.sss__hint { font-size: 11px; }
}
@media (prefers-reduced-motion: reduce) {
.sss__track { transition: none !important; }
}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);
}
.stage {
position: relative;
}
.sss {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
}
.sss__viewport {
position: relative;
width: 200px;
height: 150px;
overflow: hidden;
border-radius: 16px;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
touch-action: none;
overscroll-behavior: contain;
cursor: grab;
}
.sss__track {
position: absolute;
left: 0;
top: 0;
width: 100%;
will-change: transform;
}
.sss__panel {
width: 100%;
height: 150px;
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
font-weight: 700;
color: #17141a;
box-sizing: border-box;
}
.sss__panel--1 {
background: #4cd4a6;
}
.sss__panel--2 {
background: #6ec8ff;
}
.sss__panel--3 {
background: #b9a0ff;
}
.sss__hint {
color: #17141a;
font-size: 12px;
text-align: center;
}
@media (max-width: 340px) {
.sss {
gap: 8px;
}
.sss__viewport {
width: 152px;
height: 86px;
}
.sss__panel {
height: 86px;
font-size: 13px;
}
.sss__hint {
font-size: 11px;
}
}
@media (prefers-reduced-motion: reduce) {
.sss__track {
transition: none !important;
}
}(function () {
var root = document.getElementById('sss');
var viewport = document.getElementById('sssViewport');
var track = document.getElementById('sssTrack');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
// 계산 겹: 놓인 위치 값(pos)이 가장 가까운 구간 값(target = round(pos/SECTION_H)*SECTION_H)을
// 향해 스프링 방정식(SPRING_K·SPRING_C)으로 매 프레임 가속도를 쌓아 다가간다 — 목표를 살짝
// 지나쳤다 되돌아오길 반복하며 잦아든다(과소감쇠 스프링).
var SECTION_H = 150;
var SECTION_COUNT = 3;
var SPRING_K = 150;
var SPRING_C = 7.5;
var pos = 0, vel = 0, raf = null, maxScroll = SECTION_H * (SECTION_COUNT - 1);
function clampFree(v) { return Math.max(-maxScroll, Math.min(0, v)); }
function paint() { track.style.transform = 'translateY(' + pos.toFixed(1) + 'px)'; }
function stopSpring() { if (raf !== null) { cancelAnimationFrame(raf); raf = null; } }
function nearestTarget() { return clampFree(Math.round(pos / SECTION_H) * SECTION_H); }
function springTick(target) {
var force = -SPRING_K * (pos - target) - SPRING_C * vel;
vel += force * (1 / 60) * 0.06;
pos += vel;
paint();
if (Math.abs(pos - target) < 0.4 && Math.abs(vel) < 0.4) {
pos = target; vel = 0; paint(); raf = null; return;
}
raf = requestAnimationFrame(function () { springTick(target); });
}
function snap() {
var target = nearestTarget();
if (reduce) { pos = target; vel = 0; paint(); return; }
stopSpring();
raf = requestAnimationFrame(function () { springTick(target); });
}
var wheelTimer = null;
function wireReal() {
var dragging = false, startY = 0, startPos = 0;
viewport.addEventListener('pointerdown', function (e) {
root.classList.remove('is-demo'); stopAutoplay(); stopSpring();
dragging = true; startY = e.clientY; startPos = pos; vel = 0;
viewport.setPointerCapture(e.pointerId);
});
viewport.addEventListener('pointermove', function (e) {
if (!dragging) { return; }
pos = clampFree(startPos + (e.clientY - startY));
paint();
});
viewport.addEventListener('pointerup', function () {
if (!dragging) { return; }
dragging = false;
snap();
});
viewport.addEventListener('wheel', function (e) {
e.preventDefault();
root.classList.remove('is-demo'); stopAutoplay(); stopSpring();
pos = clampFree(pos - e.deltaY * 0.5);
paint();
if (wheelTimer) { clearTimeout(wheelTimer); }
wheelTimer = setTimeout(snap, 140);
}, { passive: false });
}
wireReal();
paint();
// 자동 루프: 다음 구간으로 자유 이동 값을 옮긴 뒤 스프링 정착을 반복 재현한다.
var autoTimer = null, section = 0;
function autoTick() {
section = (section + 1) % SECTION_COUNT;
pos = clampFree(-section * SECTION_H + 26);
paint();
snap();
}
function stopAutoplay() { if (autoTimer) { clearInterval(autoTimer); autoTimer = null; } if (wheelTimer) { clearTimeout(wheelTimer); wheelTimer = null; } }
if (!reduce) { autoTimer = setInterval(autoTick, 1300); autoTick(); }
})();import { useEffect, useRef } from "react";
import styles from "./ScrollMomentumSpringSectionSnap.module.scss";
export type ScrollMomentumSpringSectionSnapProps = {
color?: string;
};
export default function ScrollMomentumSpringSectionSnap({
color = "#4cd4a6",
}: ScrollMomentumSpringSectionSnapProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const root = rootRef.current;
if (!root) return;
const viewport = root.querySelector<HTMLDivElement>(`.${styles.viewport}`);
const track = root.querySelector<HTMLDivElement>(`.${styles.track}`);
if (!viewport || !track) return;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
// 계산 겹: 놓인 위치 값(pos)이 가장 가까운 구간 값(target = round(pos/SECTION_H)*SECTION_H)을
// 향해 스프링 방정식(SPRING_K·SPRING_C)으로 매 프레임 가속도를 쌓아 다가간다 — 목표를 살짝
// 지나쳤다 되돌아오길 반복하며 잦아든다(과소감쇠 스프링).
const SECTION_H = 150;
const SECTION_COUNT = 3;
const SPRING_K = 150;
const SPRING_C = 7.5;
let pos = 0, vel = 0, raf: number | null = null;
const maxScroll = SECTION_H * (SECTION_COUNT - 1);
function clampFree(v: number) { return Math.max(-maxScroll, Math.min(0, v)); }
function paint() { track!.style.transform = "translateY(" + pos.toFixed(1) + "px)"; }
function stopSpring() { if (raf !== null) { cancelAnimationFrame(raf); raf = null; } }
function nearestTarget() { return clampFree(Math.round(pos / SECTION_H) * SECTION_H); }
function springTick(target: number) {
const force = -SPRING_K * (pos - target) - SPRING_C * vel;
vel += force * (1 / 60) * 0.06;
pos += vel;
paint();
if (Math.abs(pos - target) < 0.4 && Math.abs(vel) < 0.4) {
pos = target; vel = 0; paint(); raf = null; return;
}
raf = requestAnimationFrame(() => springTick(target));
}
function snap() {
const target = nearestTarget();
if (reduce) { pos = target; vel = 0; paint(); return; }
stopSpring();
raf = requestAnimationFrame(() => springTick(target));
}
let wheelTimer: number | null = null;
let dragging = false, startY = 0, startPos = 0;
function onPointerDown(e: PointerEvent) {
stopAutoplay(); stopSpring();
dragging = true; startY = e.clientY; startPos = pos; vel = 0;
viewport!.setPointerCapture(e.pointerId);
}
function onPointerMove(e: PointerEvent) {
if (!dragging) return;
pos = clampFree(startPos + (e.clientY - startY));
paint();
}
function onPointerUp() {
if (!dragging) return;
dragging = false;
snap();
}
function onWheel(e: WheelEvent) {
e.preventDefault();
stopAutoplay(); stopSpring();
pos = clampFree(pos - e.deltaY * 0.5);
paint();
if (wheelTimer !== null) window.clearTimeout(wheelTimer);
wheelTimer = window.setTimeout(snap, 140);
}
viewport.addEventListener("pointerdown", onPointerDown);
viewport.addEventListener("pointermove", onPointerMove);
viewport.addEventListener("pointerup", onPointerUp);
viewport.addEventListener("wheel", onWheel, { passive: false });
paint();
let autoTimer: number | null = null, section = 0;
function autoTick() {
section = (section + 1) % SECTION_COUNT;
pos = clampFree(-section * SECTION_H + 26);
paint();
snap();
}
function stopAutoplay() {
if (autoTimer !== null) { window.clearInterval(autoTimer); autoTimer = null; }
if (wheelTimer !== null) { window.clearTimeout(wheelTimer); wheelTimer = null; }
}
if (!reduce) { autoTimer = window.setInterval(autoTick, 1300); autoTick(); }
return () => {
stopAutoplay();
stopSpring();
viewport.removeEventListener("pointerdown", onPointerDown);
viewport.removeEventListener("pointermove", onPointerMove);
viewport.removeEventListener("pointerup", onPointerUp);
viewport.removeEventListener("wheel", onWheel);
};
}, []);
return (
<div ref={rootRef} className={styles.stage}>
<div className={styles.sss}>
<div className={styles.viewport} role="application" aria-label="구간 스냅 스크롤판">
<div className={styles.track}>
<section className={`${styles.panel} ${styles.panel1}`} style={{ background: color }}>구간 1</section>
<section className={`${styles.panel} ${styles.panel2}`}>구간 2</section>
<section className={`${styles.panel} ${styles.panel3}`}>구간 3</section>
</div>
</div>
<p className={styles.hint}>드래그로 스크롤한 뒤 놓으면 가까운 구간 값으로 스프링 정착합니다</p>
</div>
</div>
);
}04 · 区間スナップスプリング — 離されたスクロール位置の値が最も近い区間の値へ、スプリング減衰しながら落ち着く方式 — 計測 累積面積 20.706% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
数値が示すこと
23枚すべてで04は動き、累積面積20.706%、平均強度88.7です — スプリングが目標の区間を一度通り過ぎては戻る動きを、ほぼ毎フレーム繰り返している数字です。
function clampFree(v) { return Math.max(-maxScroll, Math.min(0, v)); }
function paint() { track.style.transform = 'translateY(' + pos.toFixed(1) + 'px)'; }
function stopSpring() { if (raf !== null) { cancelAnimationFrame(raf); raf = null; } }
function nearestTarget() { return clampFree(Math.round(pos / SECTION_H) * SECTION_H); }
function springTick(target) {
var force = -SPRING_K * (pos - target) - SPRING_C * vel;
vel += force * (1 / 60) * 0.06;
pos += vel;
paint();
if (Math.abs(pos - target) < 0.4 && Math.abs(vel) < 0.4) {
pos = target; vel = 0; paint(); raf = null; return;
}
raf = requestAnimationFrame(function () { springTick(target); });
}
function snap() {
var target = nearestTarget();
if (reduce) { pos = target; vel = 0; paint(); return; }
stopSpring();
raf = requestAnimationFrame(function () { springTick(target); });
}
05レイヤー別減衰パララックス — スクロールが止まった後も、レイヤーごとに異なる減衰率で移動の値が縮み続ける方式
レイヤーごとの減衰率の値が異なり、止まった後も移動の値がそれぞれ違う速さで小さくなります。ヒーローの背景レイヤーやブランドストーリーページでよく使われる方式で、ドラッグを離した瞬間からレイヤー同士が目に見えてばらけて見えます。

<main class="stage">
<div class="dpl is-demo" id="dpl">
<div class="dpl__viewport" id="dplViewport" role="application" aria-label="レイヤーパララックス実験パネル">
<div class="dpl__layer dpl__layer--far" id="dplFar"></div>
<div class="dpl__layer dpl__layer--mid" id="dplMid"></div>
<div class="dpl__layer dpl__layer--near" id="dplNear"></div>
</div>
<p class="dpl__hint">横にドラッグして離すと、レイヤーごとに違う速さで収まります</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹(레이어별 감쇠 적분)은 JS 에 있다 — 여기는 정적 배치만.
@include stage($stage-ink);
.stage { position: relative; }
.dpl {
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-3;
}
.dpl__viewport {
position: relative;
width: 230px;
height: 130px;
overflow: hidden;
border-radius: $r-card;
background: rgba($stage-paper, .06);
border: 1px solid rgba($stage-paper, .16);
touch-action: none;
overscroll-behavior: contain;
cursor: grab;
}
.dpl__layer {
position: absolute;
left: 0;
width: 640px;
height: 32px;
will-change: transform;
background-repeat: repeat-x;
background-size: 80px 100%;
}
.dpl__layer--far {
top: $sp-3;
opacity: .35;
background-image: radial-gradient(circle, $subject-sky 0 8px, transparent 9px);
}
.dpl__layer--mid {
top: 50%;
margin-top: -16px;
opacity: .6;
background-image: radial-gradient(circle, $subject-lilac 0 10px, transparent 11px);
}
.dpl__layer--near {
bottom: $sp-3;
opacity: 1;
background-image: radial-gradient(circle, $subject-mint 0 13px, transparent 14px);
}
.dpl__hint {
color: rgba($stage-paper, .72);
font-size: 12px;
text-align: center;
}
@media (max-width: 340px) {
.dpl__viewport { width: 176px; height: 96px; }
}
@media (prefers-reduced-motion: reduce) {
.dpl__layer { transition: none !important; }
}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);
}
.stage {
position: relative;
}
.dpl {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
}
.dpl__viewport {
position: relative;
width: 230px;
height: 130px;
overflow: hidden;
border-radius: 16px;
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.16);
touch-action: none;
overscroll-behavior: contain;
cursor: grab;
}
.dpl__layer {
position: absolute;
left: 0;
width: 640px;
height: 32px;
will-change: transform;
background-repeat: repeat-x;
background-size: 80px 100%;
}
.dpl__layer--far {
top: 12px;
opacity: 0.35;
background-image: radial-gradient(circle, #6ec8ff 0 8px, transparent 9px);
}
.dpl__layer--mid {
top: 50%;
margin-top: -16px;
opacity: 0.6;
background-image: radial-gradient(circle, #b9a0ff 0 10px, transparent 11px);
}
.dpl__layer--near {
bottom: 12px;
opacity: 1;
background-image: radial-gradient(circle, #4cd4a6 0 13px, transparent 14px);
}
.dpl__hint {
color: rgba(255, 255, 255, 0.72);
font-size: 12px;
text-align: center;
}
@media (max-width: 340px) {
.dpl__viewport {
width: 176px;
height: 96px;
}
}
@media (prefers-reduced-motion: reduce) {
.dpl__layer {
transition: none !important;
}
}(function () {
var root = document.getElementById('dpl');
var viewport = document.getElementById('dplViewport');
var layers = [
{ el: document.getElementById('dplFar'), speed: 0.35, decay: 0.988, pos: 0, vel: 0 },
{ el: document.getElementById('dplMid'), speed: 0.65, decay: 0.95, pos: 0, vel: 0 },
{ el: document.getElementById('dplNear'), speed: 1.0, decay: 0.88, pos: 0, vel: 0 }
];
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
// 계산 겹: 입력 값(inputVel)에 레이어마다 다른 SPEED 를 곱해 각자의 속도(layer.vel)를 만들고,
// 입력이 끊기면 레이어마다 다른 DECAY 값으로 각자 감쇠한다 — 감쇠율이 클수록(1에 가까울수록)
// 더 오래 움직인다. 그래서 손을 떼도 레이어들이 서로 다른 시점에 멈춘다.
var WRAP = 320;
var raf = null;
function clampWrap(v) { var m = v % WRAP; if (m > 0) { m -= WRAP; } return m; }
function paint() {
for (var i = 0; i < layers.length; i++) {
layers[i].el.style.transform = 'translateX(' + layers[i].pos.toFixed(1) + 'px)';
}
}
function stopLoop() { if (raf !== null) { cancelAnimationFrame(raf); raf = null; } }
var inputActive = false;
function loopTick() {
var moving = false;
for (var i = 0; i < layers.length; i++) {
var L = layers[i];
if (!inputActive) { L.vel *= L.decay; if (reduce) { L.vel = 0; } }
L.pos = clampWrap(L.pos + L.vel);
if (Math.abs(L.vel) > 0.02) { moving = true; }
}
paint();
if (reduce || (!moving && !inputActive)) { raf = null; return; }
raf = requestAnimationFrame(loopTick);
}
function ensureLoop() { if (raf === null) { raf = requestAnimationFrame(loopTick); } }
function setInput(v) {
for (var i = 0; i < layers.length; i++) { layers[i].vel = v * layers[i].speed; }
ensureLoop();
}
function wireReal() {
viewport.addEventListener('wheel', function (e) {
e.preventDefault();
root.classList.remove('is-demo'); stopAutoplay();
inputActive = true;
setInput(-e.deltaY * 0.5 - e.deltaX * 0.5);
clearTimeout(viewport._wt);
viewport._wt = setTimeout(function () { inputActive = false; }, 90);
}, { passive: false });
var dragging = false, lastX = 0, lastT = 0;
viewport.addEventListener('pointerdown', function (e) {
root.classList.remove('is-demo'); stopAutoplay();
dragging = true; inputActive = true; lastX = e.clientX; lastT = performance.now();
viewport.setPointerCapture(e.pointerId);
});
viewport.addEventListener('pointermove', function (e) {
if (!dragging) { return; }
var t = performance.now();
var dt = Math.max(1, t - lastT);
var v = (e.clientX - lastX) / dt * 16.6;
setInput(v);
lastX = e.clientX; lastT = t;
});
viewport.addEventListener('pointerup', function () {
if (!dragging) { return; }
dragging = false; inputActive = false;
ensureLoop();
});
}
wireReal();
paint();
// 자동 루프: 강하게 밀었다가 손을 떼 레이어별 감쇠 경로를 반복 재현한다.
var autoTimer = null, phase = 0;
function autoTick() {
phase += 1;
var dir = (phase % 2 === 0) ? -1 : 1;
inputActive = true;
setInput(dir * 6.5);
setTimeout(function () { inputActive = false; ensureLoop(); }, 160);
}
function stopAutoplay() { if (autoTimer) { clearInterval(autoTimer); autoTimer = null; } }
if (!reduce) { autoTimer = setInterval(autoTick, 1500); autoTick(); }
})();import { useEffect, useRef } from "react";
import styles from "./ScrollMomentumDecayParallaxLayers.module.scss";
export type ScrollMomentumDecayParallaxLayersProps = {
color?: string;
};
type Layer = { el: HTMLDivElement; speed: number; decay: number; pos: number; vel: number };
export default function ScrollMomentumDecayParallaxLayers({
color = "#4cd4a6",
}: ScrollMomentumDecayParallaxLayersProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const root = rootRef.current;
if (!root) return;
const viewport = root.querySelector<HTMLDivElement>(`.${styles.viewport}`);
const farEl = root.querySelector<HTMLDivElement>(`.${styles.far}`);
const midEl = root.querySelector<HTMLDivElement>(`.${styles.mid}`);
const nearEl = root.querySelector<HTMLDivElement>(`.${styles.near}`);
if (!viewport || !farEl || !midEl || !nearEl) return;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
// 계산 겹: 입력 값(inputVel)에 레이어마다 다른 SPEED 를 곱해 각자의 속도(layer.vel)를 만들고,
// 입력이 끊기면 레이어마다 다른 DECAY 값으로 각자 감쇠한다 — 감쇠율이 클수록(1에 가까울수록)
// 더 오래 움직인다. 그래서 손을 떼도 레이어들이 서로 다른 시점에 멈춘다.
const WRAP = 320;
const layers: Layer[] = [
{ el: farEl, speed: 0.35, decay: 0.988, pos: 0, vel: 0 },
{ el: midEl, speed: 0.65, decay: 0.95, pos: 0, vel: 0 },
{ el: nearEl, speed: 1.0, decay: 0.88, pos: 0, vel: 0 },
];
let raf: number | null = null;
let inputActive = false;
function clampWrap(v: number) { let m = v % WRAP; if (m > 0) m -= WRAP; return m; }
function paint() {
for (const L of layers) { L.el.style.transform = "translateX(" + L.pos.toFixed(1) + "px)"; }
}
function stopLoop() { if (raf !== null) { cancelAnimationFrame(raf); raf = null; } }
function loopTick() {
let moving = false;
for (const L of layers) {
if (!inputActive) { L.vel *= L.decay; if (reduce) L.vel = 0; }
L.pos = clampWrap(L.pos + L.vel);
if (Math.abs(L.vel) > 0.02) moving = true;
}
paint();
if (reduce || (!moving && !inputActive)) { raf = null; return; }
raf = requestAnimationFrame(loopTick);
}
function ensureLoop() { if (raf === null) raf = requestAnimationFrame(loopTick); }
function setInput(v: number) {
for (const L of layers) { L.vel = v * L.speed; }
ensureLoop();
}
let wheelTimer: number | null = null;
function onWheel(e: WheelEvent) {
e.preventDefault();
stopAutoplay();
inputActive = true;
setInput(-e.deltaY * 0.5 - e.deltaX * 0.5);
if (wheelTimer !== null) window.clearTimeout(wheelTimer);
wheelTimer = window.setTimeout(() => { inputActive = false; }, 90);
}
let dragging = false, lastX = 0, lastT = 0;
function onPointerDown(e: PointerEvent) {
stopAutoplay();
dragging = true; inputActive = true; lastX = e.clientX; lastT = performance.now();
viewport!.setPointerCapture(e.pointerId);
}
function onPointerMove(e: PointerEvent) {
if (!dragging) return;
const t = performance.now();
const dt = Math.max(1, t - lastT);
const v = (e.clientX - lastX) / dt * 16.6;
setInput(v);
lastX = e.clientX; lastT = t;
}
function onPointerUp() {
if (!dragging) return;
dragging = false; inputActive = false;
ensureLoop();
}
viewport.addEventListener("wheel", onWheel, { passive: false });
viewport.addEventListener("pointerdown", onPointerDown);
viewport.addEventListener("pointermove", onPointerMove);
viewport.addEventListener("pointerup", onPointerUp);
paint();
let autoTimer: number | null = null, phase = 0;
let autoReleaseTimer: number | null = null;
function autoTick() {
phase += 1;
const dir = phase % 2 === 0 ? -1 : 1;
inputActive = true;
setInput(dir * 6.5);
autoReleaseTimer = window.setTimeout(() => { inputActive = false; ensureLoop(); }, 160);
}
function stopAutoplay() {
if (autoTimer !== null) { window.clearInterval(autoTimer); autoTimer = null; }
if (autoReleaseTimer !== null) { window.clearTimeout(autoReleaseTimer); autoReleaseTimer = null; }
}
if (!reduce) { autoTimer = window.setInterval(autoTick, 1500); autoTick(); }
return () => {
stopAutoplay();
stopLoop();
viewport.removeEventListener("wheel", onWheel);
viewport.removeEventListener("pointerdown", onPointerDown);
viewport.removeEventListener("pointermove", onPointerMove);
viewport.removeEventListener("pointerup", onPointerUp);
};
}, []);
return (
<div ref={rootRef} className={styles.stage}>
<div className={styles.dpl}>
<div className={styles.viewport} role="application" aria-label="레이어 패럴랙스 실험판">
<div className={`${styles.layer} ${styles.far}`} />
<div className={`${styles.layer} ${styles.mid}`} />
<div
className={`${styles.layer} ${styles.near}`}
style={{ backgroundImage: `radial-gradient(circle, ${color} 0 13px, transparent 14px)` }}
/>
</div>
<p className={styles.hint}>가로로 드래그한 뒤 놓으면 레이어마다 다른 속도로 잦아듭니다</p>
</div>
</div>
);
}05 · レイヤー別減衰パララックス — スクロールが止まった後も、レイヤーごとに異なる減衰率で移動の値が縮み続ける方式 — 計測 累積面積 9.958% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
数値が示すこと
23枚すべてで05は動き、累積面積9.958%、平均強度83.6です — 01〜04より小さいのは、レイヤーごとにドラッグ量の一部分しか動かないからです。
function clampWrap(v) { var m = v % WRAP; if (m > 0) { m -= WRAP; } return m; }
function paint() {
for (var i = 0; i < layers.length; i++) {
layers[i].el.style.transform = 'translateX(' + layers[i].pos.toFixed(1) + 'px)';
}
}
function stopLoop() { if (raf !== null) { cancelAnimationFrame(raf); raf = null; } }
var inputActive = false;
function loopTick() {
var moving = false;
for (var i = 0; i < layers.length; i++) {
var L = layers[i];
if (!inputActive) { L.vel *= L.decay; if (reduce) { L.vel = 0; } }
L.pos = clampWrap(L.pos + L.vel);
if (Math.abs(L.vel) > 0.02) { moving = true; }
}
paint();
if (reduce || (!moving && !inputActive)) { raf = null; return; }
raf = requestAnimationFrame(loopTick);
}
function ensureLoop() { if (raf === null) { raf = requestAnimationFrame(loopTick); } }
function setInput(v) {
for (var i = 0; i < layers.length; i++) { layers[i].vel = v * layers[i].speed; }
ensureLoop();
}
06落下定着 — スクロールで表示されると要素の位置の値が重力の値で落下し、床の値で跳ねてから定着する方式
表示された要素の位置の値が落下し、床の値で跳ね返ることを繰り返しながら小さくなっていきます。カード型のリストや商品グリッドセクションでよく使われる方式で、スクロールで視界に入った瞬間にカードが落ちて弾みながら定着する様子がそのまま見えます。

<main class="stage">
<div class="gsc is-demo" id="gsc">
<div class="gsc__viewport" id="gscViewport" role="application" aria-label="落下定着カードリスト" tabindex="0">
<div class="gsc__pad-top"></div>
<div class="gsc__card" data-card>カード1</div>
<div class="gsc__spacer"></div>
<div class="gsc__card" data-card>カード2</div>
<div class="gsc__spacer"></div>
<div class="gsc__card" data-card>カード3</div>
<div class="gsc__pad-bottom"></div>
</div>
<p class="gsc__hint">スクロールしてカードが見えると、重力値で落下し床で跳ねて定着します</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹(중력 낙하·반동 적분)은 JS 에 있다 — 여기는 정적 배치만.
@include stage($stage-orange);
.stage { position: relative; }
.gsc {
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-3;
}
.gsc__viewport {
position: relative;
width: 220px;
height: 150px;
overflow-y: auto;
overflow-x: hidden;
border-radius: $r-card;
background: rgba($stage-paper, .9);
border: 1px solid rgba($subject-ink, .12);
touch-action: pan-y;
overscroll-behavior: contain;
scrollbar-width: thin;
}
.gsc__pad-top { height: 24px; }
.gsc__pad-bottom { height: 24px; }
.gsc__spacer { height: 84px; }
.gsc__card {
margin: 0 $sp-3;
height: 48px;
border-radius: $r-control;
background: $subject-pink;
color: $subject-ink;
display: flex;
align-items: center;
justify-content: center;
font-size: 13px;
font-weight: 600;
box-shadow: $shadow-raised;
opacity: 0;
transform: translateY(-40px);
will-change: transform, opacity;
}
.gsc__hint {
color: rgba($subject-ink, .72);
font-size: 12px;
text-align: center;
}
@media (max-width: 340px) {
.gsc { gap: $sp-2; }
.gsc__viewport { width: 176px; height: 90px; }
.gsc__card { height: 34px; }
.gsc__spacer { height: 48px; }
.gsc__hint { font-size: 11px; }
}
@media (prefers-reduced-motion: reduce) {
.gsc__card { transition: none !important; animation: none !important; }
}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);
}
.stage {
position: relative;
}
.gsc {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
}
.gsc__viewport {
position: relative;
width: 220px;
height: 150px;
overflow-y: auto;
overflow-x: hidden;
border-radius: 16px;
background: rgba(255, 255, 255, 0.9);
border: 1px solid rgba(23, 20, 26, 0.12);
touch-action: pan-y;
overscroll-behavior: contain;
scrollbar-width: thin;
}
.gsc__pad-top {
height: 24px;
}
.gsc__pad-bottom {
height: 24px;
}
.gsc__spacer {
height: 84px;
}
.gsc__card {
margin: 0 12px;
height: 48px;
border-radius: 12px;
background: #ff9ebb;
color: #17141a;
display: flex;
align-items: center;
justify-content: center;
font-size: 13px;
font-weight: 600;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
opacity: 0;
transform: translateY(-40px);
will-change: transform, opacity;
}
.gsc__hint {
color: rgba(23, 20, 26, 0.72);
font-size: 12px;
text-align: center;
}
@media (max-width: 340px) {
.gsc {
gap: 8px;
}
.gsc__viewport {
width: 176px;
height: 90px;
}
.gsc__card {
height: 34px;
}
.gsc__spacer {
height: 48px;
}
.gsc__hint {
font-size: 11px;
}
}
@media (prefers-reduced-motion: reduce) {
.gsc__card {
transition: none !important;
animation: none !important;
}
}(function () {
var root = document.getElementById('gsc');
var viewport = document.getElementById('gscViewport');
var cards = Array.prototype.slice.call(document.querySelectorAll('[data-card]'));
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
// 계산 겹: 카드가 뷰포트에 들어오는 순간 위치 값(y)이 START_Y 에서 GRAVITY 가속으로 떨어지다
// FLOOR_Y(0) 에 닿으면 속도 값에 RESTITUTION 을 곱한 만큼만 되튀어(반동), STOP_VEL 밑이면 멈춘다.
var START_Y = -40;
var FLOOR_Y = 0;
var GRAVITY = 0.85;
var RESTITUTION = 0.42;
var STOP_VEL = 0.6;
function dropTick(card, y, vel) {
vel += GRAVITY;
y += vel;
if (y >= FLOOR_Y) {
y = FLOOR_Y;
vel = -vel * RESTITUTION;
if (Math.abs(vel) < STOP_VEL) {
card.style.transform = 'translateY(0px)';
card.dataset.done = '1';
return;
}
}
card.style.transform = 'translateY(' + y.toFixed(1) + 'px)';
requestAnimationFrame(function () { dropTick(card, y, vel); });
}
function settle(card) {
if (card.dataset.done) { return; }
card.style.opacity = '1';
if (reduce) { card.style.transform = 'translateY(0px)'; card.dataset.done = '1'; return; }
card.dataset.done = '1';
dropTick(card, START_Y, 0);
}
var io = new IntersectionObserver(function (entries) {
entries.forEach(function (en) { if (en.isIntersecting) { settle(en.target); } });
}, { root: viewport, threshold: 0.6 });
cards.forEach(function (c) { io.observe(c); });
// 프로그램이 scrollTop 을 옮길 때도 네이티브 scroll 이벤트가 뜬다 — 자동 루프 중임을 이 플래그로
// 표시해 "진짜 사용자 스크롤"과 구분한다(안 하면 자동 루프 첫 걸음에서 곧장 스스로를 멈춘다).
var autoScrolling = false;
function wireReal() {
viewport.addEventListener('scroll', function () {
if (autoScrolling) { return; }
root.classList.remove('is-demo'); stopAutoplay();
}, { passive: true });
viewport.addEventListener('keydown', function (e) {
if (e.key !== 'ArrowDown' && e.key !== 'ArrowUp') { return; }
root.classList.remove('is-demo'); stopAutoplay();
viewport.scrollTop += (e.key === 'ArrowDown' ? 40 : -40);
});
}
wireReal();
// 자동 루프: 스크롤 값을 아래로 서서히 옮겨 카드마다 진입 시점의 낙하 정착을 반복 재현한다.
var autoTimer = null, raf2 = null;
function autoStep() {
var max = viewport.scrollHeight - viewport.clientHeight;
var next = viewport.scrollTop + 1.6;
autoScrolling = true;
if (next >= max) {
viewport.scrollTop = max;
raf2 = null;
setTimeout(function () { if (root.classList.contains('is-demo')) { cards.forEach(function (c) { delete c.dataset.done; c.style.transform = 'translateY(' + START_Y + 'px)'; c.style.opacity = '0'; }); autoScrolling = true; viewport.scrollTop = 0; } }, 500);
return;
}
viewport.scrollTop = next;
raf2 = requestAnimationFrame(autoStep);
}
function stopAutoplay() { if (autoTimer) { clearInterval(autoTimer); autoTimer = null; } if (raf2) { cancelAnimationFrame(raf2); raf2 = null; } }
if (!reduce) { autoTimer = setInterval(function () { if (raf2 === null) { raf2 = requestAnimationFrame(autoStep); } }, 1800); autoTimer && requestAnimationFrame(autoStep); }
})();import { useEffect, useRef } from "react";
import styles from "./ScrollMomentumGravitySettleCard.module.scss";
export type ScrollMomentumGravitySettleCardProps = {
color?: string;
};
export default function ScrollMomentumGravitySettleCard({
color = "#f9a8d4",
}: ScrollMomentumGravitySettleCardProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const root = rootRef.current;
if (!root) return;
const viewport = root.querySelector<HTMLDivElement>(`.${styles.viewport}`);
const cards = Array.from(root.querySelectorAll<HTMLDivElement>(`.${styles.card}`));
if (!viewport || cards.length === 0) return;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
// 계산 겹: 카드가 뷰포트에 들어오는 순간 위치 값(y)이 START_Y 에서 GRAVITY 가속으로 떨어지다
// FLOOR_Y(0) 에 닿으면 속도 값에 RESTITUTION 을 곱한 만큼만 되튀어(반동), STOP_VEL 밑이면 멈춘다.
const START_Y = -40;
const FLOOR_Y = 0;
const GRAVITY = 0.85;
const RESTITUTION = 0.42;
const STOP_VEL = 0.6;
function dropTick(card: HTMLDivElement, y: number, vel: number) {
vel += GRAVITY;
y += vel;
if (y >= FLOOR_Y) {
y = FLOOR_Y;
vel = -vel * RESTITUTION;
if (Math.abs(vel) < STOP_VEL) {
card.style.transform = "translateY(0px)";
card.dataset.done = "1";
return;
}
}
card.style.transform = "translateY(" + y.toFixed(1) + "px)";
requestAnimationFrame(() => dropTick(card, y, vel));
}
function settle(card: HTMLDivElement) {
if (card.dataset.done) return;
card.style.opacity = "1";
if (reduce) { card.style.transform = "translateY(0px)"; card.dataset.done = "1"; return; }
card.dataset.done = "1";
dropTick(card, START_Y, 0);
}
const io = new IntersectionObserver((entries) => {
entries.forEach((en) => { if (en.isIntersecting) settle(en.target as HTMLDivElement); });
}, { root: viewport, threshold: 0.6 });
cards.forEach((c) => io.observe(c));
// 프로그램이 scrollTop 을 옮길 때도 네이티브 scroll 이벤트가 뜬다 — 자동 루프 중임을 이 플래그로
// 표시해 "진짜 사용자 스크롤"과 구분한다.
let autoScrolling = false;
function onScroll() {
if (autoScrolling) return;
root!.classList.remove(styles.isDemo);
stopAutoplay();
}
function onKeydown(e: KeyboardEvent) {
if (e.key !== "ArrowDown" && e.key !== "ArrowUp") return;
root!.classList.remove(styles.isDemo);
stopAutoplay();
viewport!.scrollTop += e.key === "ArrowDown" ? 40 : -40;
}
viewport.addEventListener("scroll", onScroll, { passive: true });
viewport.addEventListener("keydown", onKeydown);
let autoTimer: number | null = null;
let raf2: number | null = null;
function autoStep() {
const max = viewport!.scrollHeight - viewport!.clientHeight;
const next = viewport!.scrollTop + 1.6;
autoScrolling = true;
if (next >= max) {
viewport!.scrollTop = max;
raf2 = null;
window.setTimeout(() => {
if (root!.classList.contains(styles.isDemo)) {
cards.forEach((c) => { delete c.dataset.done; c.style.transform = "translateY(" + START_Y + "px)"; c.style.opacity = "0"; });
autoScrolling = true;
viewport!.scrollTop = 0;
}
}, 500);
return;
}
viewport!.scrollTop = next;
raf2 = requestAnimationFrame(autoStep);
}
function stopAutoplay() {
if (autoTimer !== null) { window.clearInterval(autoTimer); autoTimer = null; }
if (raf2 !== null) { cancelAnimationFrame(raf2); raf2 = null; }
}
if (!reduce) {
autoTimer = window.setInterval(() => { if (raf2 === null) raf2 = requestAnimationFrame(autoStep); }, 1800);
raf2 = requestAnimationFrame(autoStep);
}
return () => {
io.disconnect();
stopAutoplay();
viewport.removeEventListener("scroll", onScroll);
viewport.removeEventListener("keydown", onKeydown);
};
}, []);
return (
<div ref={rootRef} className={`${styles.stage} ${styles.isDemo}`}>
<div className={styles.gsc}>
<div
className={styles.viewport}
role="application"
aria-label="낙하 정착 카드 목록"
tabIndex={0}
>
<div className={styles.padTop} />
<div className={styles.card}>카드 1</div>
<div className={styles.spacer} />
<div className={styles.card} style={{ background: color }}>카드 2</div>
<div className={styles.spacer} />
<div className={styles.card}>카드 3</div>
<div className={styles.padBottom} />
</div>
<p className={styles.hint}>스크롤해 카드가 보이면 중력 값으로 떨어지다 바닥에서 튕기며 정착합니다</p>
</div>
</div>
);
}06 · 落下定着 — スクロールで表示されると要素の位置の値が重力の値で落下し、床の値で跳ねてから定着する方式 — 計測 累積面積 22.223% · 動いたフレーム 14/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
数値が示すこと
23枚のうち06が動くのは14枚、累積面積22.223%、平均強度47.4です — 9つの中で累積面積が最大なのは、落ちて跳ねるカードが実際に縦方向の距離を移動するからです。
function dropTick(card, y, vel) {
vel += GRAVITY;
y += vel;
if (y >= FLOOR_Y) {
y = FLOOR_Y;
vel = -vel * RESTITUTION;
if (Math.abs(vel) < STOP_VEL) {
card.style.transform = 'translateY(0px)';
card.dataset.done = '1';
return;
}
}
card.style.transform = 'translateY(' + y.toFixed(1) + 'px)';
requestAnimationFrame(function () { dropTick(card, y, vel); });
}
function settle(card) {
if (card.dataset.done) { return; }
card.style.opacity = '1';
if (reduce) { card.style.transform = 'translateY(0px)'; card.dataset.done = '1'; return; }
card.dataset.done = '1';
dropTick(card, START_Y, 0);
}
07摩擦減速 — 入力の値が途切れた後、速度の値が摩擦の分だけ徐々に落ちていく方式
入力が途切れると速度の値が一定の摩擦の値ずつ落ち、やがて元の速度の値に戻ります。ロゴのマーキーやニュースティッカーバナーでよく使われる方式で、ドラッグやホイールは基準の速さから値をずらすだけで、帯自体が完全に止まることはありません。

<main class="stage">
<div class="fmd is-demo" id="fmd">
<div class="fmd__viewport" id="fmdViewport" role="application" aria-label="マーキードラッグ実験パネル" tabindex="0">
<div class="fmd__track" id="fmdTrack">
<span class="fmd__chip fmd__chip--a">01</span>
<span class="fmd__chip fmd__chip--b">02</span>
<span class="fmd__chip fmd__chip--c">03</span>
<span class="fmd__chip fmd__chip--a">04</span>
<span class="fmd__chip fmd__chip--b">05</span>
<span class="fmd__chip fmd__chip--c">06</span>
<span class="fmd__chip fmd__chip--a">01</span>
<span class="fmd__chip fmd__chip--b">02</span>
<span class="fmd__chip fmd__chip--c">03</span>
<span class="fmd__chip fmd__chip--a">04</span>
<span class="fmd__chip fmd__chip--b">05</span>
<span class="fmd__chip fmd__chip--c">06</span>
</div>
</div>
<p class="fmd__hint">ドラッグまたはホイールを回すと速度が変わり、摩擦で基準速度に戻ります</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹(마찰 복귀 적분)은 JS 에 있다 — 여기는 정적 배치만.
@include stage($stage-paper);
.stage { position: relative; }
.fmd {
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-3;
}
.fmd__viewport {
position: relative;
width: 245px;
height: 64px;
overflow: hidden;
border-radius: $r-card;
background: rgba($subject-ink, .05);
border: 1px solid rgba($subject-ink, .12);
touch-action: none;
overscroll-behavior: contain;
cursor: grab;
display: flex;
align-items: center;
}
.fmd__track {
display: flex;
align-items: center;
gap: $sp-2;
will-change: transform;
padding-left: $sp-3;
}
.fmd__chip {
flex: 0 0 auto;
width: 56px;
height: 32px;
border-radius: $r-pill;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
font-weight: 700;
color: $subject-ink;
}
.fmd__chip--a { background: $subject-mint; }
.fmd__chip--b { background: $subject-sky; }
.fmd__chip--c { background: $subject-lilac; }
.fmd__hint {
color: rgba($subject-ink, .72);
font-size: 12px;
text-align: center;
}
@media (max-width: 340px) {
.fmd__viewport { width: 196px; height: 52px; }
.fmd__chip { width: 44px; height: 26px; }
}
@media (prefers-reduced-motion: reduce) {
.fmd__track { transition: none !important; }
}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);
}
.stage {
position: relative;
}
.fmd {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
}
.fmd__viewport {
position: relative;
width: 245px;
height: 64px;
overflow: hidden;
border-radius: 16px;
background: rgba(23, 20, 26, 0.05);
border: 1px solid rgba(23, 20, 26, 0.12);
touch-action: none;
overscroll-behavior: contain;
cursor: grab;
display: flex;
align-items: center;
}
.fmd__track {
display: flex;
align-items: center;
gap: 8px;
will-change: transform;
padding-left: 12px;
}
.fmd__chip {
flex: 0 0 auto;
width: 56px;
height: 32px;
border-radius: 999px;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
font-weight: 700;
color: #17141a;
}
.fmd__chip--a {
background: #4cd4a6;
}
.fmd__chip--b {
background: #6ec8ff;
}
.fmd__chip--c {
background: #b9a0ff;
}
.fmd__hint {
color: rgba(23, 20, 26, 0.72);
font-size: 12px;
text-align: center;
}
@media (max-width: 340px) {
.fmd__viewport {
width: 196px;
height: 52px;
}
.fmd__chip {
width: 44px;
height: 26px;
}
}
@media (prefers-reduced-motion: reduce) {
.fmd__track {
transition: none !important;
}
}(function () {
var root = document.getElementById('fmd');
var viewport = document.getElementById('fmdViewport');
var track = document.getElementById('fmdTrack');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
// 계산 겹: 평소엔 BASE_SPEED 로 계속 흐르다가, 드래그·휠로 속도(vel)가 그 값에서 벗어나면
// 매 프레임 FRICTION_PULL 만큼만 BASE_SPEED 쪽으로 되돌아온다 — 순간 정지가 아니라 서서히 복귀.
var CHIP_W = 56, GAP = 10, SET_COUNT = 6;
var WRAP = SET_COUNT * (CHIP_W + GAP);
var BASE_SPEED = -0.9;
var FRICTION_PULL = 0.06;
var pos = 0, vel = BASE_SPEED;
var inputActive = false;
var raf = null;
function clampWrap(v) { var m = v % WRAP; if (m > 0) { m -= WRAP; } return m; }
function paint() { track.style.transform = 'translateX(' + pos.toFixed(1) + 'px)'; }
function loopTick() {
if (reduce) { raf = null; return; }
if (!inputActive) { vel += (BASE_SPEED - vel) * FRICTION_PULL; }
pos = clampWrap(pos + vel);
paint();
raf = requestAnimationFrame(loopTick);
}
function ensureLoop() { if (raf === null && !reduce) { raf = requestAnimationFrame(loopTick); } }
ensureLoop();
paint();
function wireReal() {
var dragging = false, lastX = 0, lastT = 0;
viewport.addEventListener('pointerdown', function (e) {
root.classList.remove('is-demo'); stopAutoplay();
dragging = true; inputActive = true; lastX = e.clientX; lastT = performance.now();
viewport.setPointerCapture(e.pointerId);
});
viewport.addEventListener('pointermove', function (e) {
if (!dragging) { return; }
var t = performance.now();
var dt = Math.max(1, t - lastT);
var dx = e.clientX - lastX;
vel = dx / dt * 16.6;
pos = clampWrap(pos + dx);
paint();
lastX = e.clientX; lastT = t;
});
viewport.addEventListener('pointerup', function () {
if (!dragging) { return; }
dragging = false; inputActive = false;
ensureLoop();
});
viewport.addEventListener('wheel', function (e) {
e.preventDefault();
root.classList.remove('is-demo'); stopAutoplay();
inputActive = true;
vel += -e.deltaY * 0.4 - e.deltaX * 0.4;
ensureLoop();
clearTimeout(viewport._wt);
viewport._wt = setTimeout(function () { inputActive = false; }, 80);
}, { passive: false });
}
wireReal();
// 자동 루프: 주기적으로 강하게 튕겨 마찰이 기준 속도로 되돌리는 과정을 반복 재현한다.
var autoTimer = null, phase = 0;
function autoTick() {
phase += 1;
var dir = (phase % 2 === 0) ? 1 : -1;
inputActive = true;
vel = dir * 7 + BASE_SPEED;
ensureLoop();
setTimeout(function () { inputActive = false; }, 140);
}
function stopAutoplay() { if (autoTimer) { clearInterval(autoTimer); autoTimer = null; } }
if (!reduce) { autoTimer = setInterval(autoTick, 1500); autoTick(); }
})();import { useEffect, useRef } from "react";
import styles from "./ScrollMomentumFrictionMarqueeDrag.module.scss";
export type ScrollMomentumFrictionMarqueeDragProps = {
color?: string;
};
export default function ScrollMomentumFrictionMarqueeDrag({
color = "#4cd4a6",
}: ScrollMomentumFrictionMarqueeDragProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const root = rootRef.current;
if (!root) return;
const viewport = root.querySelector<HTMLDivElement>(`.${styles.viewport}`);
const track = root.querySelector<HTMLDivElement>(`.${styles.track}`);
if (!viewport || !track) return;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
// 계산 겹: 평소엔 BASE_SPEED 로 계속 흐르다가, 드래그·휠로 속도(vel)가 그 값에서 벗어나면
// 매 프레임 FRICTION_PULL 만큼만 BASE_SPEED 쪽으로 되돌아온다 — 순간 정지가 아니라 서서히 복귀.
const CHIP_W = 56, GAP = 10, SET_COUNT = 6;
const WRAP = SET_COUNT * (CHIP_W + GAP);
const BASE_SPEED = -0.9;
const FRICTION_PULL = 0.06;
let pos = 0, vel = BASE_SPEED;
let inputActive = false;
let raf: number | null = null;
function clampWrap(v: number) { let m = v % WRAP; if (m > 0) m -= WRAP; return m; }
function paint() { track!.style.transform = "translateX(" + pos.toFixed(1) + "px)"; }
function loopTick() {
if (reduce) { raf = null; return; }
if (!inputActive) { vel += (BASE_SPEED - vel) * FRICTION_PULL; }
pos = clampWrap(pos + vel);
paint();
raf = requestAnimationFrame(loopTick);
}
function ensureLoop() { if (raf === null && !reduce) raf = requestAnimationFrame(loopTick); }
ensureLoop();
paint();
let dragging = false, lastX = 0, lastT = 0;
function onPointerDown(e: PointerEvent) {
stopAutoplay();
dragging = true; inputActive = true; lastX = e.clientX; lastT = performance.now();
viewport!.setPointerCapture(e.pointerId);
}
function onPointerMove(e: PointerEvent) {
if (!dragging) return;
const t = performance.now();
const dt = Math.max(1, t - lastT);
const dx = e.clientX - lastX;
vel = (dx / dt) * 16.6;
pos = clampWrap(pos + dx);
paint();
lastX = e.clientX; lastT = t;
}
function onPointerUp() {
if (!dragging) return;
dragging = false; inputActive = false;
ensureLoop();
}
function onWheel(e: WheelEvent) {
e.preventDefault();
stopAutoplay();
inputActive = true;
vel += -e.deltaY * 0.4 - e.deltaX * 0.4;
ensureLoop();
if (wheelTimer !== null) window.clearTimeout(wheelTimer);
wheelTimer = window.setTimeout(() => { inputActive = false; }, 80);
}
let wheelTimer: number | null = null;
viewport.addEventListener("pointerdown", onPointerDown);
viewport.addEventListener("pointermove", onPointerMove);
viewport.addEventListener("pointerup", onPointerUp);
viewport.addEventListener("wheel", onWheel, { passive: false });
let autoTimer: number | null = null, phase = 0;
function autoTick() {
phase += 1;
const dir = phase % 2 === 0 ? 1 : -1;
inputActive = true;
vel = dir * 7 + BASE_SPEED;
ensureLoop();
window.setTimeout(() => { inputActive = false; }, 140);
}
function stopAutoplay() { if (autoTimer !== null) { window.clearInterval(autoTimer); autoTimer = null; } }
if (!reduce) { autoTimer = window.setInterval(autoTick, 1500); autoTick(); }
return () => {
stopAutoplay();
if (raf !== null) cancelAnimationFrame(raf);
viewport.removeEventListener("pointerdown", onPointerDown);
viewport.removeEventListener("pointermove", onPointerMove);
viewport.removeEventListener("pointerup", onPointerUp);
viewport.removeEventListener("wheel", onWheel);
};
}, []);
const labels = ["01", "02", "03", "04", "05", "06", "01", "02", "03", "04", "05", "06"];
const cls = ["a", "b", "c"];
return (
<div ref={rootRef} className={styles.stage}>
<div className={styles.fmd}>
<div className={styles.viewport} role="application" aria-label="마퀴 드래그 실험판" tabIndex={0}>
<div className={styles.track}>
{labels.map((label, i) => (
<span
key={i}
className={`${styles.chip} ${styles[cls[i % 3]]}`}
style={i === 0 ? { background: color } : undefined}
>
{label}
</span>
))}
</div>
</div>
<p className={styles.hint}>드래그하거나 휠을 굴리면 속도가 바뀌었다가 마찰로 기준 속도로 되돌아옵니다</p>
</div>
</div>
);
}07 · 摩擦減速 — 入力の値が途切れた後、速度の値が摩擦の分だけ徐々に落ちていく方式 — 計測 累積面積 5.444% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
数値が示すこと
23枚すべてで07は動き、累積面積5.444%、平均強度105.0です — 入力が何もなくても帯自身の基準速度が毎フレーム動き続けている数字です。
var CHIP_W = 56, GAP = 10, SET_COUNT = 6;
var WRAP = SET_COUNT * (CHIP_W + GAP);
var BASE_SPEED = -0.9;
var FRICTION_PULL = 0.06;
var pos = 0, vel = BASE_SPEED;
var inputActive = false;
var raf = null;
function clampWrap(v) { var m = v % WRAP; if (m > 0) { m -= WRAP; } return m; }
function paint() { track.style.transform = 'translateX(' + pos.toFixed(1) + 'px)'; }
function loopTick() {
if (reduce) { raf = null; return; }
if (!inputActive) { vel += (BASE_SPEED - vel) * FRICTION_PULL; }
pos = clampWrap(pos + vel);
paint();
raf = requestAnimationFrame(loopTick);
}
function ensureLoop() { if (raf === null && !reduce) { raf = requestAnimationFrame(loopTick); } }
08遅延追従 — 追従する値が実際のスクロールの値にすぐ追従せず、遅延の値だけ遅れて追いかける方式
追従する値が実際のスクロールの値より遅延の値だけ遅れて追いかけ、少しずつ追いつきます。自作スクロールバーやドキュメント型のコンテンツページでよく使われる方式で、速くスクロールしたときだけ、つまみが本物のスクロール位置から遅れて見えます。
<main class="stage">
<div class="lst is-demo" id="lst">
<div class="lst__viewport" id="lstViewport" role="application" aria-label="遅延スクロールバー実験パネル" tabindex="0">
<div class="lst__track" id="lstTrack">
<div class="lst__row">行1</div>
<div class="lst__row">行2</div>
<div class="lst__row">行3</div>
<div class="lst__row">行4</div>
<div class="lst__row">行5</div>
<div class="lst__row">行6</div>
<div class="lst__row">行7</div>
<div class="lst__row">行8</div>
</div>
<div class="lst__rail" id="lstRail">
<div class="lst__thumb" id="lstThumb"></div>
</div>
</div>
<p class="lst__hint">スクロールするとリストはすぐ追従し、バーのつまみは遅れて徐々に追いつきます</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹(지연 보간 적분)은 JS 에 있다 — 여기는 정적 배치만.
@include stage($stage-ink);
.stage { position: relative; }
.lst {
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-3;
}
.lst__viewport {
position: relative;
width: 220px;
height: 150px;
overflow: hidden;
border-radius: $r-card;
background: rgba($stage-paper, .06);
border: 1px solid rgba($stage-paper, .16);
touch-action: none;
overscroll-behavior: contain;
cursor: grab;
}
.lst__track {
position: absolute;
inset: 0;
will-change: transform;
}
.lst__row {
height: 40px;
margin: 0 $sp-6 0 $sp-3;
border-radius: $r-control;
background: rgba($stage-paper, .1);
color: $stage-paper;
display: flex;
align-items: center;
padding-left: $sp-2;
font-size: 12px;
box-sizing: border-box;
}
.lst__rail {
position: absolute;
top: $sp-2;
right: $sp-2;
bottom: $sp-2;
width: 6px;
border-radius: $r-pill;
background: rgba($stage-paper, .12);
}
.lst__thumb {
position: absolute;
top: 0;
left: 0;
width: 6px;
border-radius: $r-pill;
background: $subject-sky;
will-change: transform;
}
.lst__hint {
color: rgba($stage-paper, .72);
font-size: 12px;
text-align: center;
}
@media (max-width: 340px) {
.lst { gap: $sp-2; }
.lst__viewport { width: 176px; height: 90px; }
.lst__row { height: 30px; }
.lst__hint { font-size: 11px; }
}
@media (prefers-reduced-motion: reduce) {
.lst__thumb { transition: none !important; }
}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);
}
.stage {
position: relative;
}
.lst {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
}
.lst__viewport {
position: relative;
width: 220px;
height: 150px;
overflow: hidden;
border-radius: 16px;
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.16);
touch-action: none;
overscroll-behavior: contain;
cursor: grab;
}
.lst__track {
position: absolute;
inset: 0;
will-change: transform;
}
.lst__row {
height: 40px;
margin: 0 24px 0 12px;
border-radius: 12px;
background: rgba(255, 255, 255, 0.1);
color: #ffffff;
display: flex;
align-items: center;
padding-left: 8px;
font-size: 12px;
box-sizing: border-box;
}
.lst__rail {
position: absolute;
top: 8px;
right: 8px;
bottom: 8px;
width: 6px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.12);
}
.lst__thumb {
position: absolute;
top: 0;
left: 0;
width: 6px;
border-radius: 999px;
background: #6ec8ff;
will-change: transform;
}
.lst__hint {
color: rgba(255, 255, 255, 0.72);
font-size: 12px;
text-align: center;
}
@media (max-width: 340px) {
.lst {
gap: 8px;
}
.lst__viewport {
width: 176px;
height: 90px;
}
.lst__row {
height: 30px;
}
.lst__hint {
font-size: 11px;
}
}
@media (prefers-reduced-motion: reduce) {
.lst__thumb {
transition: none !important;
}
}(function () {
var root = document.getElementById('lst');
var viewport = document.getElementById('lstViewport');
var track = document.getElementById('lstTrack');
var rail = document.getElementById('lstRail');
var thumb = document.getElementById('lstThumb');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
// 계산 겹: 목록 내용은 입력에 1:1 로 즉시 따라가지만(scrollPos), 손잡이 위치(thumbY)는
// 목표값(targetThumbY)을 매 프레임 LAG 만큼만 따라잡는 지수 보간(lerp)이라 항상 한 박자 늦게 도착한다.
var LAG = 0.15;
var SNAP_EPS = 0.3;
var rowH = 40, rowCount = 8;
var maxScroll = Math.max(0, rowH * rowCount - viewport.clientHeight);
var scrollPos = 0;
var thumbY = 0;
var raf = null;
// BD19 — 막대·손잡이 실측 픽셀은 getBoundingClientRect 로 잰다(짐작값 금지).
var railRect = rail.getBoundingClientRect();
var railH = railRect.height;
var thumbH = Math.max(28, railH * (viewport.clientHeight / (rowH * rowCount)));
thumb.style.height = thumbH.toFixed(1) + 'px';
function paintTrack() { track.style.transform = 'translateY(-' + scrollPos.toFixed(1) + 'px)'; }
function targetThumbY() { return maxScroll <= 0 ? 0 : (scrollPos / maxScroll) * (railH - thumbH); }
function paintThumb() { thumb.style.transform = 'translateY(' + thumbY.toFixed(1) + 'px)'; }
function loopTick() {
var target = targetThumbY();
if (reduce) { thumbY = target; paintThumb(); raf = null; return; }
thumbY += (target - thumbY) * LAG;
if (Math.abs(target - thumbY) < SNAP_EPS) {
thumbY = target; paintThumb(); raf = null; return;
}
paintThumb();
raf = requestAnimationFrame(loopTick);
}
function ensureLoop() { if (raf === null) { raf = requestAnimationFrame(loopTick); } }
function setScroll(v) {
scrollPos = Math.max(0, Math.min(maxScroll, v));
paintTrack();
ensureLoop();
}
paintTrack(); paintThumb();
function wireReal() {
viewport.addEventListener('wheel', function (e) {
e.preventDefault();
root.classList.remove('is-demo'); stopAutoplay();
setScroll(scrollPos + e.deltaY);
}, { passive: false });
var dragging = false, lastY = 0;
viewport.addEventListener('pointerdown', function (e) {
root.classList.remove('is-demo'); stopAutoplay();
dragging = true; lastY = e.clientY;
viewport.setPointerCapture(e.pointerId);
});
viewport.addEventListener('pointermove', function (e) {
if (!dragging) { return; }
setScroll(scrollPos - (e.clientY - lastY));
lastY = e.clientY;
});
viewport.addEventListener('pointerup', function () { dragging = false; });
viewport.addEventListener('keydown', function (e) {
if (e.key !== 'ArrowDown' && e.key !== 'ArrowUp') { return; }
root.classList.remove('is-demo'); stopAutoplay();
setScroll(scrollPos + (e.key === 'ArrowDown' ? 40 : -40));
});
}
wireReal();
// 자동 루프: 바닥까지 내렸다 다시 위로 올려 손잡이가 뒤늦게 따라붙는 지연을 반복 재현한다.
var autoTimer = null, phase = 0;
function autoTick() {
phase += 1;
setScroll(phase % 2 === 0 ? 0 : maxScroll);
}
function stopAutoplay() { if (autoTimer) { clearInterval(autoTimer); autoTimer = null; } }
if (!reduce) { autoTimer = setInterval(autoTick, 1500); autoTick(); }
})();import { useEffect, useRef } from "react";
import styles from "./ScrollMomentumLagScrollbarThumb.module.scss";
export type ScrollMomentumLagScrollbarThumbProps = {
color?: string;
};
export default function ScrollMomentumLagScrollbarThumb({
color = "#6ec8ff",
}: ScrollMomentumLagScrollbarThumbProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const root = rootRef.current;
if (!root) return;
const viewport = root.querySelector<HTMLDivElement>(`.${styles.viewport}`);
const track = root.querySelector<HTMLDivElement>(`.${styles.track}`);
const rail = root.querySelector<HTMLDivElement>(`.${styles.rail}`);
const thumb = root.querySelector<HTMLDivElement>(`.${styles.thumb}`);
if (!viewport || !track || !rail || !thumb) return;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
// 계산 겹: 목록 내용은 입력에 1:1 로 즉시 따라가지만(scrollPos), 손잡이 위치(thumbY)는
// 목표값(targetThumbY)을 매 프레임 LAG 만큼만 따라잡는 지수 보간(lerp)이라 항상 한 박자 늦게 도착한다.
const LAG = 0.15;
const SNAP_EPS = 0.3;
const rowH = 40, rowCount = 8;
const maxScroll = Math.max(0, rowH * rowCount - viewport.clientHeight);
let scrollPos = 0;
let thumbY = 0;
let raf: number | null = null;
// BD19 — 막대·손잡이 실측 픽셀은 getBoundingClientRect 로 잰다(짐작값 금지).
const railRect = rail.getBoundingClientRect();
const railH = railRect.height;
const thumbH = Math.max(28, railH * (viewport.clientHeight / (rowH * rowCount)));
thumb.style.height = thumbH.toFixed(1) + "px";
function paintTrack() { track!.style.transform = "translateY(-" + scrollPos.toFixed(1) + "px)"; }
function targetThumbY() { return maxScroll <= 0 ? 0 : (scrollPos / maxScroll) * (railH - thumbH); }
function paintThumb() { thumb!.style.transform = "translateY(" + thumbY.toFixed(1) + "px)"; }
function loopTick() {
const target = targetThumbY();
if (reduce) { thumbY = target; paintThumb(); raf = null; return; }
thumbY += (target - thumbY) * LAG;
if (Math.abs(target - thumbY) < SNAP_EPS) { thumbY = target; paintThumb(); raf = null; return; }
paintThumb();
raf = requestAnimationFrame(loopTick);
}
function ensureLoop() { if (raf === null) raf = requestAnimationFrame(loopTick); }
function setScroll(v: number) {
scrollPos = Math.max(0, Math.min(maxScroll, v));
paintTrack();
ensureLoop();
}
paintTrack(); paintThumb();
function onWheel(e: WheelEvent) {
e.preventDefault();
stopAutoplay();
setScroll(scrollPos + e.deltaY);
}
let dragging = false, lastY = 0;
function onPointerDown(e: PointerEvent) {
stopAutoplay();
dragging = true; lastY = e.clientY;
viewport!.setPointerCapture(e.pointerId);
}
function onPointerMove(e: PointerEvent) {
if (!dragging) return;
setScroll(scrollPos - (e.clientY - lastY));
lastY = e.clientY;
}
function onPointerUp() { dragging = false; }
function onKeydown(e: KeyboardEvent) {
if (e.key !== "ArrowDown" && e.key !== "ArrowUp") return;
stopAutoplay();
setScroll(scrollPos + (e.key === "ArrowDown" ? 40 : -40));
}
viewport.addEventListener("wheel", onWheel, { passive: false });
viewport.addEventListener("pointerdown", onPointerDown);
viewport.addEventListener("pointermove", onPointerMove);
viewport.addEventListener("pointerup", onPointerUp);
viewport.addEventListener("keydown", onKeydown);
let autoTimer: number | null = null, phase = 0;
function autoTick() { phase += 1; setScroll(phase % 2 === 0 ? 0 : maxScroll); }
function stopAutoplay() { if (autoTimer !== null) { window.clearInterval(autoTimer); autoTimer = null; } }
if (!reduce) { autoTimer = window.setInterval(autoTick, 1500); autoTick(); }
return () => {
stopAutoplay();
if (raf !== null) cancelAnimationFrame(raf);
viewport.removeEventListener("wheel", onWheel);
viewport.removeEventListener("pointerdown", onPointerDown);
viewport.removeEventListener("pointermove", onPointerMove);
viewport.removeEventListener("pointerup", onPointerUp);
viewport.removeEventListener("keydown", onKeydown);
};
}, []);
const rows = [1, 2, 3, 4, 5, 6, 7, 8];
return (
<div ref={rootRef} className={styles.stage}>
<div className={styles.lst}>
<div className={styles.viewport} role="application" aria-label="지연 스크롤바 실험판" tabIndex={0}>
<div className={styles.track}>
{rows.map((n) => (
<div key={n} className={styles.row}>행 {n}</div>
))}
</div>
<div className={styles.rail}>
<div className={styles.thumb} style={{ background: color }} />
</div>
</div>
<p className={styles.hint}>스크롤하면 목록은 바로 따라가고, 막대 손잡이는 뒤처졌다 서서히 따라붙습니다</p>
</div>
</div>
);
}08 · 遅延追従 — 追従する値が実際のスクロールの値にすぐ追従せず、遅延の値だけ遅れて追いかける方式 — 計測 累積面積 1.782% · 動いたフレーム 9/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
数値が示すこと
23枚のうち08が動くのは9枚、累積面積1.782%、平均強度108.0です — 9つの中で面積が最小なのは、動くのがつまみ自身だけで、リストや背後のトラックは一切動かないからです。
// BD19 — レール・つまみの実測ピクセルは getBoundingClientRect で測る(推測値禁止)。
var railRect = rail.getBoundingClientRect();
var railH = railRect.height;
var thumbH = Math.max(28, railH * (viewport.clientHeight / (rowH * rowCount)));
thumb.style.height = thumbH.toFixed(1) + 'px';
function paintTrack() { track.style.transform = 'translateY(-' + scrollPos.toFixed(1) + 'px)'; }
function targetThumbY() { return maxScroll <= 0 ? 0 : (scrollPos / maxScroll) * (railH - thumbH); }
function paintThumb() { thumb.style.transform = 'translateY(' + thumbY.toFixed(1) + 'px)'; }
function loopTick() {
var target = targetThumbY();
if (reduce) { thumbY = target; paintThumb(); raf = null; return; }
thumbY += (target - thumbY) * LAG;
if (Math.abs(target - thumbY) < SNAP_EPS) {
thumbY = target; paintThumb(); raf = null; return;
}
paintThumb();
raf = requestAnimationFrame(loopTick);
}
09ラバープル — スクロール方向が変わった瞬間、値がゴムのように引かれてから戻る方式
方向が変わった瞬間、値が逆に引かれ、元の値へ弾むように戻ります。上部のナビゲーションバーやモバイルアプリ風ヘッダーでよく使われる方式で、一定方向のスクロール中は静かなまま、訪問者が向きを変えた瞬間だけ反応します。

<main class="stage">
<div class="rpn is-demo" id="rpn">
<div class="rpn__frame">
<div class="rpn__nav" id="rpnNav"><span>区間案内</span></div>
<div class="rpn__viewport" id="rpnViewport" role="application" aria-label="方向転換ラバーナビ実験パネル" tabindex="0">
<div class="rpn__track" id="rpnTrack">
<div class="rpn__row">セクション1</div>
<div class="rpn__row">セクション2</div>
<div class="rpn__row">セクション3</div>
<div class="rpn__row">セクション4</div>
<div class="rpn__row">セクション5</div>
<div class="rpn__row">セクション6</div>
</div>
</div>
</div>
<p class="rpn__hint">スクロール方向を切り替えるたびに、上のナビバーがゴムのように弾みます</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹(방향전환 감지 + 스프링 적분)은 JS 에 있다 — 여기는 정적 배치만.
@include stage($stage-yellow);
.stage { position: relative; }
.rpn {
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-3;
}
.rpn__frame {
width: 220px;
border-radius: $r-card;
overflow: hidden;
background: rgba($subject-ink, .06);
border: 1px solid rgba($subject-ink, .14);
}
.rpn__nav {
height: 34px;
background: $subject-blue-deep;
color: $stage-paper;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
font-weight: 700;
transform-origin: center top;
will-change: transform;
}
.rpn__viewport {
position: relative;
width: 100%;
height: 150px;
overflow: hidden;
touch-action: none;
overscroll-behavior: contain;
cursor: grab;
box-sizing: border-box;
}
.rpn__track {
position: absolute;
inset: 0;
will-change: transform;
}
.rpn__row {
height: 40px;
margin: 0 $sp-3;
border-radius: $r-control;
background: rgba($subject-ink, .07);
color: $subject-ink;
display: flex;
align-items: center;
padding-left: $sp-2;
font-size: 12px;
box-sizing: border-box;
}
.rpn__hint {
color: rgba($subject-ink, .72);
font-size: 12px;
text-align: center;
}
@media (max-width: 340px) {
.rpn { gap: $sp-2; }
.rpn__frame { width: 176px; }
.rpn__nav { height: 26px; font-size: 11px; }
.rpn__viewport { height: 80px; }
.rpn__row { height: 30px; }
.rpn__hint { font-size: 11px; }
}
@media (prefers-reduced-motion: reduce) {
.rpn__nav { transition: none !important; }
}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);
}
.stage {
position: relative;
}
.rpn {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
}
.rpn__frame {
width: 220px;
border-radius: 16px;
overflow: hidden;
background: rgba(23, 20, 26, 0.06);
border: 1px solid rgba(23, 20, 26, 0.14);
}
.rpn__nav {
height: 34px;
background: #0a4fe8;
color: #ffffff;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
font-weight: 700;
transform-origin: center top;
will-change: transform;
}
.rpn__viewport {
position: relative;
width: 100%;
height: 150px;
overflow: hidden;
touch-action: none;
overscroll-behavior: contain;
cursor: grab;
box-sizing: border-box;
}
.rpn__track {
position: absolute;
inset: 0;
will-change: transform;
}
.rpn__row {
height: 40px;
margin: 0 12px;
border-radius: 12px;
background: rgba(23, 20, 26, 0.07);
color: #17141a;
display: flex;
align-items: center;
padding-left: 8px;
font-size: 12px;
box-sizing: border-box;
}
.rpn__hint {
color: rgba(23, 20, 26, 0.72);
font-size: 12px;
text-align: center;
}
@media (max-width: 340px) {
.rpn {
gap: 8px;
}
.rpn__frame {
width: 176px;
}
.rpn__nav {
height: 26px;
font-size: 11px;
}
.rpn__viewport {
height: 80px;
}
.rpn__row {
height: 30px;
}
.rpn__hint {
font-size: 11px;
}
}
@media (prefers-reduced-motion: reduce) {
.rpn__nav {
transition: none !important;
}
}(function () {
var root = document.getElementById('rpn');
var viewport = document.getElementById('rpnViewport');
var track = document.getElementById('rpnTrack');
var nav = document.getElementById('rpnNav');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
// 계산 겹 ①: 목록은 입력에 1:1 로 즉시 따라간다(scrollPos, 경계 clamp).
var rowH = 40, rowCount = 6;
var maxScroll = Math.max(0, rowH * rowCount - viewport.clientHeight);
var scrollPos = 0;
var lastVel = 0;
// 계산 겹 ②: 스크롤 속도(vel)의 부호가 바뀌는 순간(방향 전환)에만 네비 바에 충격을 주고,
// 그 뒤엔 스프링 적분(SPRING_K·SPRING_C)이 scale 을 1로 되돌리며 튕긴다 — 경계에 닿아서가 아니다.
var MIN_VEL = 1.2;
var KICK = 0.4;
var SPRING_K = 260;
var SPRING_C = 14;
var navScale = 1, navVel = 0;
var navRaf = null;
function paintTrack() { track.style.transform = 'translateY(-' + scrollPos.toFixed(1) + 'px)'; }
function paintNav() { nav.style.transform = 'scaleY(' + navScale.toFixed(3) + ')'; }
function navTick() {
var force = -SPRING_K * (navScale - 1) - SPRING_C * navVel;
navVel += force * 0.016;
navScale += navVel * 0.016;
if (reduce) { navScale = 1; navVel = 0; paintNav(); navRaf = null; return; }
if (Math.abs(navScale - 1) < 0.003 && Math.abs(navVel) < 0.02) {
navScale = 1; navVel = 0; paintNav(); navRaf = null; return;
}
paintNav();
navRaf = requestAnimationFrame(navTick);
}
function ensureNavLoop() { if (navRaf === null) { navRaf = requestAnimationFrame(navTick); } }
function kickNav(sign) {
if (reduce) { return; }
navVel += sign * KICK;
ensureNavLoop();
}
function applyVel(vel) {
if (lastVel !== 0 && vel !== 0 && (lastVel > 0) !== (vel > 0) && Math.abs(lastVel) > MIN_VEL && Math.abs(vel) > MIN_VEL) {
kickNav(vel > 0 ? 1 : -1);
}
if (Math.abs(vel) > 0.1) { lastVel = vel; }
}
function setScroll(v, vel) {
var next = Math.max(0, Math.min(maxScroll, v));
applyVel(vel);
scrollPos = next;
paintTrack();
}
paintTrack(); paintNav();
function wireReal() {
viewport.addEventListener('wheel', function (e) {
e.preventDefault();
root.classList.remove('is-demo'); stopAutoplay();
setScroll(scrollPos + e.deltaY, e.deltaY);
}, { passive: false });
var dragging = false, lastY = 0;
viewport.addEventListener('pointerdown', function (e) {
root.classList.remove('is-demo'); stopAutoplay();
dragging = true; lastY = e.clientY;
viewport.setPointerCapture(e.pointerId);
});
viewport.addEventListener('pointermove', function (e) {
if (!dragging) { return; }
var dy = -(e.clientY - lastY);
setScroll(scrollPos + dy, dy);
lastY = e.clientY;
});
viewport.addEventListener('pointerup', function () { dragging = false; });
viewport.addEventListener('keydown', function (e) {
if (e.key !== 'ArrowDown' && e.key !== 'ArrowUp') { return; }
root.classList.remove('is-demo'); stopAutoplay();
var dy = (e.key === 'ArrowDown' ? 40 : -40);
setScroll(scrollPos + dy, dy);
});
}
wireReal();
// 자동 루프: 아래로 갔다가 위로 되짚어 방향 전환을 주기적으로 만든다.
var autoTimer = null, phase = 0;
function autoTick() {
phase += 1;
var dir = (phase % 2 === 0) ? -1 : 1;
setScroll(scrollPos + dir * 60, dir * 60);
}
function stopAutoplay() { if (autoTimer) { clearInterval(autoTimer); autoTimer = null; } }
if (!reduce) { autoTimer = setInterval(autoTick, 900); autoTick(); }
})();import { useEffect, useRef } from "react";
import styles from "./ScrollMomentumRubberPullNav.module.scss";
export type ScrollMomentumRubberPullNavProps = {
color?: string;
};
export default function ScrollMomentumRubberPullNav({
color = "#2f6df6",
}: ScrollMomentumRubberPullNavProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const root = rootRef.current;
if (!root) return;
const viewport = root.querySelector<HTMLDivElement>(`.${styles.viewport}`);
const track = root.querySelector<HTMLDivElement>(`.${styles.track}`);
const nav = root.querySelector<HTMLDivElement>(`.${styles.nav}`);
if (!viewport || !track || !nav) return;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
// 계산 겹 ①: 목록은 입력에 1:1 로 즉시 따라간다(scrollPos, 경계 clamp).
const rowH = 40, rowCount = 6;
const maxScroll = Math.max(0, rowH * rowCount - viewport.clientHeight);
let scrollPos = 0;
let lastVel = 0;
// 계산 겹 ②: 스크롤 속도(vel)의 부호가 바뀌는 순간(방향 전환)에만 네비 바에 충격을 주고,
// 그 뒤엔 스프링 적분(SPRING_K·SPRING_C)이 scale 을 1로 되돌리며 튕긴다 — 경계에 닿아서가 아니다.
const MIN_VEL = 1.2;
const KICK = 0.4;
const SPRING_K = 260;
const SPRING_C = 14;
let navScale = 1, navVel = 0;
let navRaf: number | null = null;
function paintTrack() { track!.style.transform = "translateY(-" + scrollPos.toFixed(1) + "px)"; }
function paintNav() { nav!.style.transform = "scaleY(" + navScale.toFixed(3) + ")"; }
function navTick() {
const force = -SPRING_K * (navScale - 1) - SPRING_C * navVel;
navVel += force * 0.016;
navScale += navVel * 0.016;
if (reduce) { navScale = 1; navVel = 0; paintNav(); navRaf = null; return; }
if (Math.abs(navScale - 1) < 0.003 && Math.abs(navVel) < 0.02) {
navScale = 1; navVel = 0; paintNav(); navRaf = null; return;
}
paintNav();
navRaf = requestAnimationFrame(navTick);
}
function ensureNavLoop() { if (navRaf === null) navRaf = requestAnimationFrame(navTick); }
function kickNav(sign: number) {
if (reduce) return;
navVel += sign * KICK;
ensureNavLoop();
}
function applyVel(vel: number) {
if (lastVel !== 0 && vel !== 0 && (lastVel > 0) !== (vel > 0) && Math.abs(lastVel) > MIN_VEL && Math.abs(vel) > MIN_VEL) {
kickNav(vel > 0 ? 1 : -1);
}
if (Math.abs(vel) > 0.1) lastVel = vel;
}
function setScroll(v: number, vel: number) {
const next = Math.max(0, Math.min(maxScroll, v));
applyVel(vel);
scrollPos = next;
paintTrack();
}
paintTrack(); paintNav();
function onWheel(e: WheelEvent) {
e.preventDefault();
stopAutoplay();
setScroll(scrollPos + e.deltaY, e.deltaY);
}
let dragging = false, lastY = 0;
function onPointerDown(e: PointerEvent) {
stopAutoplay();
dragging = true; lastY = e.clientY;
viewport!.setPointerCapture(e.pointerId);
}
function onPointerMove(e: PointerEvent) {
if (!dragging) return;
const dy = -(e.clientY - lastY);
setScroll(scrollPos + dy, dy);
lastY = e.clientY;
}
function onPointerUp() { dragging = false; }
function onKeydown(e: KeyboardEvent) {
if (e.key !== "ArrowDown" && e.key !== "ArrowUp") return;
stopAutoplay();
const dy = e.key === "ArrowDown" ? 40 : -40;
setScroll(scrollPos + dy, dy);
}
viewport.addEventListener("wheel", onWheel, { passive: false });
viewport.addEventListener("pointerdown", onPointerDown);
viewport.addEventListener("pointermove", onPointerMove);
viewport.addEventListener("pointerup", onPointerUp);
viewport.addEventListener("keydown", onKeydown);
let autoTimer: number | null = null, phase = 0;
function autoTick() {
phase += 1;
const dir = phase % 2 === 0 ? -1 : 1;
setScroll(scrollPos + dir * 60, dir * 60);
}
function stopAutoplay() { if (autoTimer !== null) { window.clearInterval(autoTimer); autoTimer = null; } }
if (!reduce) { autoTimer = window.setInterval(autoTick, 900); autoTick(); }
return () => {
stopAutoplay();
if (navRaf !== null) cancelAnimationFrame(navRaf);
viewport.removeEventListener("wheel", onWheel);
viewport.removeEventListener("pointerdown", onPointerDown);
viewport.removeEventListener("pointermove", onPointerMove);
viewport.removeEventListener("pointerup", onPointerUp);
viewport.removeEventListener("keydown", onKeydown);
};
}, []);
const rows = [1, 2, 3, 4, 5, 6];
return (
<div ref={rootRef} className={styles.stage}>
<div className={styles.rpn}>
<div className={styles.frame}>
<div className={styles.nav} style={{ background: color }}><span>구간 안내</span></div>
<div className={styles.viewport} role="application" aria-label="방향 전환 러버 네비 실험판" tabIndex={0}>
<div className={styles.track}>
{rows.map((n) => (
<div key={n} className={styles.row}>섹션 {n}</div>
))}
</div>
</div>
</div>
<p className={styles.hint}>스크롤 방향을 바꾸는 순간마다 위 네비 바가 고무줄처럼 튕깁니다</p>
</div>
</div>
);
}09 · ラバープル — スクロール方向が変わった瞬間、値がゴムのように引かれてから戻る方式 — 計測 累積面積 1.796% · 動いたフレーム 9/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
数値が示すこと
23枚のうち09が動くのは9枚、累積面積1.796%、平均強度44.3です — 方向が反転した直後の数フレームだけ動き、スプリングが収まればまた静止する数字です。
function navTick() {
var force = -SPRING_K * (navScale - 1) - SPRING_C * navVel;
navVel += force * 0.016;
navScale += navVel * 0.016;
if (reduce) { navScale = 1; navVel = 0; paintNav(); navRaf = null; return; }
if (Math.abs(navScale - 1) < 0.003 && Math.abs(navVel) < 0.02) {
navScale = 1; navVel = 0; paintNav(); navRaf = null; return;
}
paintNav();
navRaf = requestAnimationFrame(navTick);
}
function ensureNavLoop() { if (navRaf === null) { navRaf = requestAnimationFrame(navTick); } }
function kickNav(sign) {
if (reduce) { return; }
navVel += sign * KICK;
ensureNavLoop();
}
function applyVel(vel) {
if (lastVel !== 0 && vel !== 0 && (lastVel > 0) !== (vel > 0) && Math.abs(lastVel) > MIN_VEL && Math.abs(vel) > MIN_VEL) {
kickNav(vel > 0 ? 1 : -1);
}
if (Math.abs(vel) > 0.1) { lastVel = vel; }
}
値がパターンを変える基準 — 減衰・質量・境界の基準
なぜ01のFRICTION定数は滑走の長さを決め、始まり方は決めないのか
FRICTIONが0.93のときは、指を離してから半拍ほどで速度の値が収まり、短い距離だけ滑ります。0.99まで上げると、まったく同じ速さで放しても数秒がかりで進み続け、短い滑りが長い慣性走行に変わります — 滑り出す瞬間の速さそのものはどちらも同じです。
なぜ06のRESTITUTION定数は跳ねる回数を決め、最初の落下の高さは決めないのか
RESTITUTIONが0.42のときは、床で一、二度低く跳ねてすぐ止まります。0.75まで上げると、同じ高さから落としても何度も高く弾み続けてから収まります — 最初の落下そのものはどちらも変わりません。
なぜ09のSPRING_K定数は跳ね返る回数を決め、跳ね返る向きは決めないのか
SPRING_Kが260のときは、方向が変わるたびにナビバーが短く硬く一度だけ弾みます。90まで下げると、同じ衝撃でもバネが柔らかくなり、何度も揺れ戻ってから静止します — どちら向きに弾むかは、方向転換そのものの向きだけで決まります。
この9つは、すでに扱った3つの記事とは別の計算軸に立っています — 動きの値がどこから来るかという軸です。【コピペOK】スクロール アニメーション CSS のみ 9選(002)は animation-timeline: view()/scroll() というブラウザが計算する進行度だけで動き、JavaScript のループも、フレームをまたいで持ち越す速度の値も一切ありません — この9つが毎フレーム読み書きする vel(や navVel・sVel)とは違う仕組みです。引っ張って更新 スマホ 9選 — 引いた距離で変わる更新のかたち(353)も毎フレーム実際にドラッグした距離を読みますが、9つのうちいくつかはその距離がしきい値を越えた瞬間に本物の更新処理を実行します — 画面の外まで届くこの状態変化は、値がただ元の位置へ収まっていくだけのこの9つには一つもありません。ボタン 押し込み UI 9選 — 押し込みの値が戻る計算の9通り(357)は02・04・09と同じ accel→vel→位置というスプリング積分ループを回していますが、357の9つはどれも一度の押し込みと解放という区切りの中で完結するのに対し、01のコースティングする vel・05のレイヤーごとの decay・07の BASE_SPEED へ戻ろうとする値は、終わりのないスクロールセッションに答え続けます。9つのファイルをまとめて1本のアーカイブとして落とし、自分の設定に差し替えて試してから使ってください。
どこで壊れるか — 連続スクロール・低スペック端末・モーション削減
コースティングの途中で方向を反転するとどうなるか
01と03はホイールの新しいイベントが届いた瞬間に stopCoast()/stopLoop() を呼び、その回のイベントの deltaY(または deltaX)だけから速度の値を新しく計算し直します。動いているコースティングに強く逆方向へフリックすると、それまで残っていた速度の値は混ざらずに捨てられ、値は反転を滑らかに経由せず新しい曲線へ飛び移ります。
非常に長く連続してスクロールし続けるとどうなるか
9つのどれも、スクロールし続けた時間そのものが積み上がるような状態は持っていません。速度・位置・減衰のどの値も、その時点の入力だけから毎フレーム計算し直すかクランプするかのどちらかなので、1秒のスクロールでも1時間のスクロールでも、同じ入力に対しては毎フレーム同じ数値が出ます。
prefers-reduced-motion がオンのときどうなるか
9つのうち5つ(02・04・06・08・09)は reduce が true になった瞬間、計算済みの正確な最終値へそのまま飛びます — 伸びや拡大率の値なら1、位置の値なら最も近い区間か遅延の目標、カードなら着地した状態で、途中を一切アニメーションしません。01と03はその代わりに、コースティングをおよそ6フレーム分進めたのと同じ値(vel * 6)だけ位置を進め、同じ処理の中で速度を0にします — これは本物のコースティングが止まる場所を正確に計算するのではなく、おおよそで見積もっているだけです。05は入力が止まった瞬間にその場で凍りつきます — reduce のときは各レイヤーの速度を0にするだけで、飛び先の目標値を一切計算しないからです。そして07が9つの中で最も厳格で、reduce が true の間は ensureLoop() 自体がループの開始を拒むため、マーキーは開始位置から一度も動きません。パスワード azjysfxp で解凍すれば、上で挙げた定数はすべて同じ index.html の中にそのまま入っています。
3つのバリエーションとアクセシビリティ
| 項目 | 定数 | 値 | 何が変わるか |
|---|---|---|---|
| 01 慣性コースティング | FRICTION |
0.93 → 0.99 |
放す瞬間の速さは同じでも、速度の値が半拍で収まるか数秒がかりで進み続けるかが変わり、短い滑りが長い慣性走行になります |
| 06 落下定着 | RESTITUTION |
0.42 → 0.75 |
同じ高さから同じように落ちても、一、二度低く跳ねて止まるか、何度も高く弾んでから収まるかが変わります |
| 09 ラバープル | SPRING_K |
260 → 90 |
同じ方向転換の衝撃でも、ナビバーが短く硬く一度だけ弾むか、柔らかく何度も揺れ戻ってから静止するかが変わります |
9つとも同じ role="application" と、パネルの内容を説明する aria-label を持っています。06・07・08・09には tabindex="0" もありますが、実際に keydown に応えるのは06・08・09だけで、06と08は矢印キーでのスクロール、09は方向転換の値を蹴る操作に使われ、07の tabindex はフォーカスできるようにするだけでキー操作そのものは持ちません。9つのどれも aria-live 領域を持たないため、ここでの値の変化はすべて視覚的な効果にとどまり読み上げられることはなく、9つとも前の章で説明した prefers-reduced-motion: reduce を確認しているので、OS側でこれをオンにしている訪問者にはループそのものが一切動きません。
FAQ
9つのパターンに物理演算ライブラリは必要ですか、それとも素の JavaScript で十分ですか
素の JavaScript で十分です — 9つとも requestAnimationFrame のループの中で、位置や速度、いくつかの定数といった少数の数値を計算しているだけで、物理エンジンはおろかブラウザ標準のスクロール・ポインター・アニメーションフレーム系 API 以外は何も読み込んでいません。
タッチパッド スクロール 慣性のような OS 標準の挙動と、この9つの計算は同じものですか
同じではありません — それは OS やブラウザ自身が制御する挙動で、ページ側の JavaScript からは基本的に関与できません。この9つは、その標準の挙動とは別に、ページ自身が requestAnimationFrame の中で位置と速度の値を計算し直すことで似た感触を作り出しています。
06だけスクロールのフレームごとではなく表示時に発火するのはなぜですか
06は scroll イベントではなく本物の IntersectionObserver に配線されているからです — カードは0.6の可視率のしきい値をそのオブザーバーが超えた瞬間にだけ落下を始め、スクロールの1ピクセルごとには反応しません。だからこそ06は、一度定着したカードを二度と再計算しない、9つの中で唯一の項目でもあります。
Sources: MDN — scroll イベント · MDN — overscroll-behavior · MDN — requestAnimationFrame · MDN — prefers-reduced-motion · MDN — IntersectionObserver









