05Per-layer decay parallax — after scrolling stops, each layer's position value keeps shrinking at its own decay rate
Each layer's own decay-rate value keeps its position value shrinking at a different pace after the stop.
Best forA hero background layer, a brand story page
WRAPdecayspeed

<main class="stage">
<div class="dpl is-demo" id="dpl">
<div class="dpl__viewport" id="dplViewport" role="application" aria-label="Layer parallax test panel">
<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">Drag sideways and release — each layer slows down at its own speed.</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 Per-layer decay parallax — after scrolling stops, each layer's position value keeps shrinking at its own decay rate — Measured area 9.958% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · Scroll Inertia CSS — 9 Value Patterns
The post this demo lives in
Scroll Inertia CSS — 9 Value PatternsOne value keeps its last scroll speed and shrinks to zero after stopping, one stretches past the boundary and springs ba See all demos in this post
If you like this effect - similar demos
Scroll04 Depth parallax layer — each particle carries its own depth value, so the same scroll distance applies at a different distance per depth valueMoving Particles Background CSS: 9 Scroll Scenes
Scroll08 Three-layer parallaxScroll Trigger Animation: 9 Engines, No GSAP
Scroll02 3-layer depth parallax9 CSS Parallax Scrolling — No JS, Scroll-Driven