05레이어별 감쇠 패럴랙스 — 스크롤이 멈춘 뒤 레이어마다 다른 감쇠율로 이동 값이 계속 줄어드는 방식
레이어마다 감쇠율 값이 달라 멈춘 뒤에도 이동 값이 저마다 다른 속도로 잦아듭니다.
쓰임히어로 배경 레이어, 브랜드 스토리 페이지
WRAPdecayspeed

<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 포함 · 관성 스크롤 9종 — 멈춘 뒤에도 값이 스스로 줄어드는 아홉 가지 방식
이 데모가 속한 글
관성 스크롤 9종 — 멈춘 뒤에도 값이 스스로 줄어드는 아홉 가지 방식관성 스크롤은 손을 뗀 뒤에도 이동·강도 값이 곧바로 0이 되지 않고 이전 값을 이어받아 계산으로 줄어드는 스크롤 인터랙션을 말하는데, 아홉 개 중 하나는 스크롤이 멈춘 뒤에도 이동 값이 속도를 이어받아 서서히 줄어 이 글의 데모 전부 보기


