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 オリジナル · SCSS と React は zip に同梱 · スクロール 慣性 css 9選 — 値が自ら収まっていく9つの計算パターン
このデモが属する記事
スクロール 慣性 css 9選 — 値が自ら収まっていく9つの計算パターン9つのうち1つはスクロールが止まった後も移動の値が直前の速度を引き継いで少しずつ小さくなり、1つは端を超えて伸びた値が離すと元の値へ弾むように戻り、もう1つは方向が変わった瞬間に値が逆に引かれてから元の値へ戻ります。「スクロール 慣性 cs この記事のデモをすべて見る


