03Scroll velocity blur — a blur value grows in proportion to the measured scroll speed value
As the speed value rises the blur value grows, and it returns to zero once scrolling stops.
Best forA gallery background image, a hero section background
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="Velocity blur gallery">
<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">The faster you scroll sideways, the blurrier it gets — it sharpens again once you stop.</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 Scroll velocity blur — a blur value grows in proportion to the measured scroll speed value — Measured area 20.219% · moving frames 18/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
Scroll02 Velocity-based spawn — the particle-spawn frequency value rises as the scroll-velocity value climbsMoving Particles Background CSS: 9 Scroll Scenes
Scroll01 Card That Grows and Sharpens With the Range ValueCSS Scroll Animation: 9 View-Timeline Examples
Scroll05 Thumb Whose Gradient Shifts by the Scroll-position ValueCustom Scrollbar CSS — 9 Patterns