03스크롤 속도 흐림 — 스크롤 속도 값이 커질수록 흐림 값이 비례해 커지는 방식
속도 값이 빨라질수록 흐림 값이 커지고, 멈추면 흐림 값이 0으로 되돌아옵니다.
쓰임갤러리 배경 이미지, 히어로 섹션 배경
VELOCITY_TO_BLURMAX_BLURCOAST_FRICTION

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

