관성 스크롤 9종 — 멈춘 뒤에도 값이 스스로 줄어드는 아홉 가지 방식
관성 스크롤은 손을 뗀 뒤에도 이동·강도 값이 곧바로 0이 되지 않고 이전 값을 이어받아 계산으로 줄어드는 스크롤 인터랙션을 말하는데, 아홉 개 중 하나는 스크롤이 멈춘 뒤에도 이동 값이 속도를 이어받아 서서히 줄어들고, 하나는 끝 지점을 넘어가면 늘어난 값이 손을 놓은 뒤 원래
아홉 개 모두 스크롤 위치·속도 값을 매 프레임 grep 가능한 상수(FRICTION·SPRING_K·GRAVITY 등)로 재계산하는 결정론적 rAF 적분입니다. 난수는 0 — 같은 입력이면 항상 같은 궤적을 그립니다. 화면의 스크롤 영역은 데모 프레임 안에 자체 컨테이너로 갇혀 있어 페이지 자체가 스크롤되지 않습니다.
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
자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip
- 01 스크롤 관성 코스팅 — 스크롤이 멈춘 뒤에도 이동 값이 속도를 이어받아 서서히 줄어드는 방식
- 02 오버스크롤 탄성 — 스크롤 끝 지점을 넘어가면 늘어난 값이 되돌아오는 방식
- 03 스크롤 속도 흐림 — 스크롤 속도 값이 커질수록 흐림 값이 비례해 커지는 방식
- 04 구간 스냅 스프링 — 놓인 스크롤 위치 값이 가장 가까운 구간 값으로 스프링 감쇠하며 정착하는 방식
- 05 레이어별 감쇠 패럴랙스 — 스크롤이 멈춘 뒤 레이어마다 다른 감쇠율로 이동 값이 계속 줄어드는 방식
- 06 낙하 정착 — 스크롤 진입 시 요소의 위치 값이 중력 값으로 떨어지다 바닥 값에서 튕기며 정착하는 방식
- 07 마찰 감속 — 입력 값이 사라진 뒤 속도 값이 마찰만큼 서서히 줄어드는 방식
- 08 지연 추적 — 뒤따르는 값이 실제 스크롤 값을 곧바로 따라가지 않고 지연 값만큼 늦게 뒤쫓는 방식
- 09 러버 풀 — 스크롤 방향이 바뀌는 순간 값이 고무줄처럼 당겨졌다 되돌아오는 방식
아홉 개의 해부 — 어떤 값이 언제 바뀌는가
아홉 개 전부 실제 사용자 입력(wheel·pointerdown/move/up·keydown·scroll 이벤트)에 반응하고, 입력만으로 자동 재생되는 항목은 하나도 없습니다 — 데모의 자동 시연 타이머는 !reduce 조건에서만 보조로 돌 뿐 리스너 자체를 대체하지 않습니다.
| 번호 | 이름 | 리스너 | 누적면적% | 강도 | 움직인 프레임/23 |
|---|---|---|---|---|---|
| 01 | 관성 코스팅 | wheel·pointerdown/move/up·resize | 20.97 | 94.7 | 20 |
| 02 | 오버스크롤 탄성 | pointerdown/move/up·wheel | 21.33 | 65.3 | 23 |
| 03 | 속도 흐림 | wheel·pointerdown/move/up·resize | 20.22 | 105.2 | 18 |
| 04 | 구간 스냅 스프링 | pointerdown/move/up·wheel | 20.71 | 88.7 | 23 |
| 05 | 레이어별 감쇠 패럴랙스 | wheel·pointerdown/move/up | 9.96 | 83.6 | 23 |
| 06 | 낙하 정착 | scroll(자기 트리거 가드)·keydown | 22.22 | 47.4 | 14 |
| 07 | 마찰 감속 마퀴 | pointerdown/move/up·wheel | 5.44 | 105.0 | 23 |
| 08 | 지연 추적 스크롤바 | wheel·pointerdown/move/up·keydown | 1.78 | 108.0 | 9 |
| 09 | 러버 풀 | wheel·pointerdown/move/up·keydown | 1.80 | 44.3 | 9 |
값이 바뀌는 지점을 계산 방식으로 묶으면 세 갈래로 나뉩니다.
속도가 죽으며 줄어드는 값 — 01·03·05·07
01·03·05·07 은 손을 뗀 순간의 속도 값을 그대로 이어받아 매 프레임 곱셈으로 줄이는 감쇠 계열입니다. 01 은 이동 값 자체를, 03 은 속도에서 파생된 흐림 값을, 05 는 레이어마다 다른 감쇠율로 여러 이동 값을, 07 은 입력이 끊긴 뒤 속도 값을 목표 속도로 되돌리는 방식으로 줄입니다.
경계를 넘거나 바닥에 닿아 튕기는 값 — 02·04·06·09
02 는 스크롤 경계를 넘긴 늘어난 값이 스프링 힘으로 되돌아오고, 04 는 놓인 위치 값이 가장 가까운 구간 값으로 스프링 감쇠하며 정착하고, 06 은 떨어지는 위치 값이 바닥 값에서 반발계수만큼 튀어 오르길 반복하고, 09 는 방향이 바뀐 순간 값이 반대로 당겨졌다가 스프링으로 되돌아옵니다.
실제 스크롤 값을 지연해 뒤쫓는 값 — 08
08 은 앞의 두 갈래와 달리 감쇠도 반발도 없이, 뒤따르는 값이 실제 스크롤 값과의 차이만큼씩 매 프레임 좁혀가며(lerp) 쫓아가는 방식입니다.
패턴별 코드
01스크롤 관성 코스팅 — 스크롤이 멈춘 뒤에도 이동 값이 속도를 이어받아 서서히 줄어드는 방식
손을 뗀 순간의 속도 값을 이어받아 이동 값이 점점 줄어들다 멈춥니다. FRICTION 이 매 프레임 속도 값에 곱해지고, MIN_VELOCITY 아래로 떨어지면 rAF 루프가 멈춥니다.
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);
}

<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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
02오버스크롤 탄성 — 스크롤 끝 지점을 넘어가면 늘어난 값이 되돌아오는 방식
끝 지점을 넘어 당기면 늘어난 값이 손을 놓은 뒤 원래 값으로 튕기듯 돌아옵니다. PULL_RESIST 로 당김 값을 억제하고, springTick 이 SPRING_K·SPRING_C 두 값으로 stretch 값을 1로 되돌립니다.
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);
}

<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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
03스크롤 속도 흐림 — 스크롤 속도 값이 커질수록 흐림 값이 비례해 커지는 방식
속도 값이 빨라질수록 흐림 값이 커지고, 멈추면 흐림 값이 0으로 되돌아옵니다. VELOCITY_TO_BLUR 가 속도 값을 흐림 값으로 환산하고 BLUR_SMOOTH 로 그 값을 부드럽게 따라갑니다. 배경은 사진이 아니라 CSS 그라디언트로 그려집니다.
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);
}

<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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
04구간 스냅 스프링 — 놓인 스크롤 위치 값이 가장 가까운 구간 값으로 스프링 감쇠하며 정착하는 방식
놓인 위치 값이 가까운 구간 값을 오가며 잦아들다 멈춥니다. nearestTarget 이 SECTION_H 배수 중 가장 가까운 값을 고르고, springTick(target) 이 이 항목만의 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); });
}

<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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
05레이어별 감쇠 패럴랙스 — 스크롤이 멈춘 뒤 레이어마다 다른 감쇠율로 이동 값이 계속 줄어드는 방식
레이어마다 감쇠율 값이 달라 멈춘 뒤에도 이동 값이 저마다 다른 속도로 잦아듭니다. 각 레이어 객체가 자신의 decay 값을 들고 있어, 같은 루프 안에서도 층마다 다른 곡선으로 값이 줄어듭니다.
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);
}

<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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
06낙하 정착 — 스크롤 진입 시 요소의 위치 값이 중력 값으로 떨어지다 바닥 값에서 튕기며 정착하는 방식
등장한 요소의 위치 값이 아래로 떨어지다 바닥 값에서 튀어 오르길 반복하며 잦아듭니다. GRAVITY 가 매 프레임 속도 값을 키우고, FLOOR_Y 에 닿으면 RESTITUTION 만큼만 속도를 남겨 반발합니다.
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); });
}

<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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
07마찰 감속 — 입력 값이 사라진 뒤 속도 값이 마찰만큼 서서히 줄어드는 방식
입력이 끊기면 속도 값이 일정한 마찰 값만큼씩 줄어들다 원래 속도 값으로 돌아옵니다. 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);
}

<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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
08지연 추적 — 뒤따르는 값이 실제 스크롤 값을 곧바로 따라가지 않고 지연 값만큼 늦게 뒤쫓는 방식
뒤따르는 값이 실제 스크롤 값을 지연 값만큼 늦게 쫓다가 서서히 따라잡습니다. LAG 값(소스 기준 0.15)만큼씩 남은 차이를 좁혀가는 lerp 방식이라 감쇠도 반발도 쓰지 않습니다.
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);
}
<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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
09러버 풀 — 스크롤 방향이 바뀌는 순간 값이 고무줄처럼 당겨졌다 되돌아오는 방식
방향이 바뀐 순간 값이 반대로 당겨졌다가 제자리 값으로 튕기듯 되돌아옵니다. kickNav 가 KICK 값만큼 navVel 에 충격을 주고, navTick 안의 이 항목 고유 SPRING_K·SPRING_C 값이 navScale 을 1로 되돌립니다.
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();
}

<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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
값이 패턴을 바꾸는 곳 — 감쇠·질량·경계 값의 기준
감쇠 값 — FRICTION·COAST_FRICTION·FRICTION_PULL
01 의 FRICTION 0.93 은 손을 뗀 뒤 반 박자 만에 속도 값이 잦아드는 짧은 관성입니다. 03 은 같은 계열의 COAST_FRICTION 0.92 를, 07 은 목표 속도로 끌어당기는 FRICTION_PULL 0.06 을 씁니다. 1에 가까울수록 속도 값이 오래 살아남고, 0에 가까울수록 곧바로 죽습니다.
질량·중력 값 — GRAVITY·RESTITUTION
06 의 GRAVITY 0.85 는 매 프레임 속도 값에 더해지는 낙하 가속이고, RESTITUTION 0.42 는 바닥에 닿을 때마다 남기는 속도 비율입니다. 값이 클수록 튀는 높이가 오래 유지됩니다.
경계·스프링 값 — SPRING_K·SPRING_C(02·04·09 각각 별개)
스프링 상수는 이 편 안에서 세 곳에 따로 있습니다 — 02 의 SPRING_K 210·SPRING_C 15, 04 의 SPRING_K 150·SPRING_C 7.5, 09 의 SPRING_K 260·SPRING_C 14 입니다. 같은 이름이라도 항목이 다르면 서로 다른 값이니, 인용할 때는 반드시 항목 번호를 함께 밝혀야 합니다. K 가 클수록 목표 값으로 되돌아가는 힘이 세지고, C 가 클수록 진동이 빨리 잦아듭니다.
어디서 깨지는가 — 빠른 연속 스크롤·저사양 기기·모션 감소
빠른 연속 스크롤에서는 01·03·05·07 처럼 속도 값을 계속 덮어쓰는 항목이 이전 rAF 틱과 새 입력 값이 겹쳐 순간적으로 튀는 값이 나올 수 있습니다. 저사양 기기에서는 06 의 GRAVITY·RESTITUTION 반복 계산이 프레임을 놓치면 반발 값이 부자연스럽게 커 보입니다. prefers-reduced-motion 이 켜지면 각 index.html 의 reduce 분기가 작동해 01·02·03·04·06·08·09 는 이동·강도 값을 즉시 목표 값으로 스냅하고, 05 는 레이어 속도 값을 0으로 고정하며, 07 은 rAF 루프 자체를 시작하지 않습니다. 데모 zip 을 받아 두면 압축 파일 안에서 각 index.html 을 열어 이 분기를 그대로 확인할 수 있는데, 그 zip 을 여는 비번은 azjysfxp 이고 패키지 안에는 항목 9개의 html·scss·js 가 전부 들어 있습니다.
변형 세 가지와 접근성
| 이름 | 값 | 느낌 |
|---|---|---|
| 관성 코스팅 지속 | 01 FRICTION 0.93 → 0.99 | FRICTION 이 0.93이면 손을 뗀 뒤 반 박자 만에 속도 값이 잦아들어 짧게 미끄러지는데, 0.99 로 올리면 같은 속도로 놓아도 몇 초씩 밀려나가며 훨씬 긴 관성 활주감이 된다 |
| 낙하 정착 탄성 | 06 RESTITUTION 0.42 → 0.75 | RESTITUTION 이 0.42면 바닥에서 한두 번 낮게 튀고 바로 멈추는데, 0.75 로 올리면 같은 높이에서 떨어져도 여러 번 높게 되튀며 한참을 튕기다 정착하는 낙하감이 된다 |
| 방향전환 스프링 반동 | 09 SPRING_K 260 → 90 | SPRING_K 가 260이면 방향을 바꿀 때 네비 바가 짧고 뻣뻣하게 한 번 튕기고 마는데, 90 으로 낮추면 같은 충격에도 스프링이 물러져 부드럽게 여러 번 출렁이며 되돌아오는 반동감이 된다 |
접근성은 소스에 실재하는 범위로만 밝힙니다. 아홉 항목의 스크롤 컨테이너는 전부 role="application"과 aria-label을 달고 있고, 06·07·08·09 네 항목에는 tabindex="0"도 있어 키보드 포커스가 들어갑니다. 06·08·09 는 keydown 리스너까지 등록돼 있어 방향키로도 값을 움직일 수 있습니다. prefers-reduced-motion은 위에서 밝힌 대로 항목별 reduce 분기가 이동·강도 값을 즉시 스냅하거나 루프를 멈추는 식으로 처리합니다.
FAQ
관성 스크롤 값과 오버스크롤 바운스 값은 같은 계산인가요?
아니요. 01·03·05·07 의 관성 값은 손을 뗀 순간의 속도 값을 그대로 이어받아 감쇠시키는 계산이고, 02·09 처럼 경계를 넘거나 방향이 바뀔 때 나오는 오버스크롤 바운스 값은 스프링 힘 방정식(-K*x - C*v)으로 목표 값을 되찾는 계산입니다. 두 계산 방식의 차이는 MDN overscroll-behavior 문서의 정의와도 맞습니다.
왜 CSS scroll-behavior: smooth 만으로는 이 아홉 가지를 구현할 수 없나요?
scroll-behavior는 정해진 목표 지점까지 브라우저가 정한 곡선으로 한 번 이동시켜 줄 뿐, 01의 감쇠나 06의 반발처럼 매 프레임 값을 계산해 바꾸는 물리 시뮬레이션은 만들지 않기 때문입니다. 이 아홉 개는 전부 requestAnimationFrame 루프 안에서 직접 값을 적분합니다.
스크롤 애니메이션 사이트를 만들 때 아홉 개를 한 페이지에 다 넣어도 되나요?
기술적으로는 가능하지만 권장하지 않습니다. 각 항목이 자체 rAF 루프를 도는 구조라 스크롤 인터랙션이 몰리는 페이지에서는 필요한 항목 한두 개만 골라 쓰는 편이 프레임 유지에 유리합니다. 데모처럼 프레임 컨테이너로 영역을 나누면 스크롤 이펙트가 서로의 값에 간섭하지 않습니다.
더 읽을거리
CSS 스크롤 애니메이션 9종은 스크롤 위치 값에 CSS animation-timeline을 그대로 묶어 재생하는 방식이라, 이 편처럼 속도·감쇠·스프링 값을 매 프레임 계산하는 관성 스크롤과는 계산 축이 다릅니다. 당겨서 새로고침 9종은 당겼다 놓는 순간의 거리 값이 임계값을 넘었는지만 판정하는 반면, 이 편은 그 이후 값이 시간에 따라 어떻게 줄거나 튕기는지를 다룹니다. 버튼 눌림 효과 9종은 버튼 자체의 질량·힘 값이 눌림·놓임 순간에만 반응하는데, 358 은 스크롤 속도·위치에서 파생되는 관성·감쇠·스프링 값을 다룬다는 점에서 입력 축이 다릅니다.









