07Friction decel — once the input value stops, a speed value slows gradually by a friction value
Once input ends, the speed value drops by a fixed friction value each step before returning to its base speed.
Best forA logo marquee, a news ticker banner
BASE_SPEEDFRICTION_PULLCHIP_W

<main class="stage">
<div class="fmd is-demo" id="fmd">
<div class="fmd__viewport" id="fmdViewport" role="application" aria-label="Marquee drag test panel" tabindex="0">
<div class="fmd__track" id="fmdTrack">
<span class="fmd__chip fmd__chip--a">01</span>
<span class="fmd__chip fmd__chip--b">02</span>
<span class="fmd__chip fmd__chip--c">03</span>
<span class="fmd__chip fmd__chip--a">04</span>
<span class="fmd__chip fmd__chip--b">05</span>
<span class="fmd__chip fmd__chip--c">06</span>
<span class="fmd__chip fmd__chip--a">01</span>
<span class="fmd__chip fmd__chip--b">02</span>
<span class="fmd__chip fmd__chip--c">03</span>
<span class="fmd__chip fmd__chip--a">04</span>
<span class="fmd__chip fmd__chip--b">05</span>
<span class="fmd__chip fmd__chip--c">06</span>
</div>
</div>
<p class="fmd__hint">Drag it or spin the wheel — the speed shifts, then friction eases it back to the base speed.</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹(마찰 복귀 적분)은 JS 에 있다 — 여기는 정적 배치만.
@include stage($stage-paper);
.stage { position: relative; }
.fmd {
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-3;
}
.fmd__viewport {
position: relative;
width: 245px;
height: 64px;
overflow: hidden;
border-radius: $r-card;
background: rgba($subject-ink, .05);
border: 1px solid rgba($subject-ink, .12);
touch-action: none;
overscroll-behavior: contain;
cursor: grab;
display: flex;
align-items: center;
}
.fmd__track {
display: flex;
align-items: center;
gap: $sp-2;
will-change: transform;
padding-left: $sp-3;
}
.fmd__chip {
flex: 0 0 auto;
width: 56px;
height: 32px;
border-radius: $r-pill;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
font-weight: 700;
color: $subject-ink;
}
.fmd__chip--a { background: $subject-mint; }
.fmd__chip--b { background: $subject-sky; }
.fmd__chip--c { background: $subject-lilac; }
.fmd__hint {
color: rgba($subject-ink, .72);
font-size: 12px;
text-align: center;
}
@media (max-width: 340px) {
.fmd__viewport { width: 196px; height: 52px; }
.fmd__chip { width: 44px; height: 26px; }
}
@media (prefers-reduced-motion: reduce) {
.fmd__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: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.stage {
position: relative;
}
.fmd {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
}
.fmd__viewport {
position: relative;
width: 245px;
height: 64px;
overflow: hidden;
border-radius: 16px;
background: rgba(23, 20, 26, 0.05);
border: 1px solid rgba(23, 20, 26, 0.12);
touch-action: none;
overscroll-behavior: contain;
cursor: grab;
display: flex;
align-items: center;
}
.fmd__track {
display: flex;
align-items: center;
gap: 8px;
will-change: transform;
padding-left: 12px;
}
.fmd__chip {
flex: 0 0 auto;
width: 56px;
height: 32px;
border-radius: 999px;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
font-weight: 700;
color: #17141a;
}
.fmd__chip--a {
background: #4cd4a6;
}
.fmd__chip--b {
background: #6ec8ff;
}
.fmd__chip--c {
background: #b9a0ff;
}
.fmd__hint {
color: rgba(23, 20, 26, 0.72);
font-size: 12px;
text-align: center;
}
@media (max-width: 340px) {
.fmd__viewport {
width: 196px;
height: 52px;
}
.fmd__chip {
width: 44px;
height: 26px;
}
}
@media (prefers-reduced-motion: reduce) {
.fmd__track {
transition: none !important;
}
}(function () {
var root = document.getElementById('fmd');
var viewport = document.getElementById('fmdViewport');
var track = document.getElementById('fmdTrack');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
// 계산 겹: 평소엔 BASE_SPEED 로 계속 흐르다가, 드래그·휠로 속도(vel)가 그 값에서 벗어나면
// 매 프레임 FRICTION_PULL 만큼만 BASE_SPEED 쪽으로 되돌아온다 — 순간 정지가 아니라 서서히 복귀.
var CHIP_W = 56, GAP = 10, SET_COUNT = 6;
var WRAP = SET_COUNT * (CHIP_W + GAP);
var BASE_SPEED = -0.9;
var FRICTION_PULL = 0.06;
var pos = 0, vel = BASE_SPEED;
var inputActive = false;
var raf = null;
function clampWrap(v) { var m = v % WRAP; if (m > 0) { m -= WRAP; } return m; }
function paint() { track.style.transform = 'translateX(' + pos.toFixed(1) + 'px)'; }
function loopTick() {
if (reduce) { raf = null; return; }
if (!inputActive) { vel += (BASE_SPEED - vel) * FRICTION_PULL; }
pos = clampWrap(pos + vel);
paint();
raf = requestAnimationFrame(loopTick);
}
function ensureLoop() { if (raf === null && !reduce) { raf = requestAnimationFrame(loopTick); } }
ensureLoop();
paint();
function wireReal() {
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 dx = e.clientX - lastX;
vel = dx / dt * 16.6;
pos = clampWrap(pos + dx);
paint();
lastX = e.clientX; lastT = t;
});
viewport.addEventListener('pointerup', function () {
if (!dragging) { return; }
dragging = false; inputActive = false;
ensureLoop();
});
viewport.addEventListener('wheel', function (e) {
e.preventDefault();
root.classList.remove('is-demo'); stopAutoplay();
inputActive = true;
vel += -e.deltaY * 0.4 - e.deltaX * 0.4;
ensureLoop();
clearTimeout(viewport._wt);
viewport._wt = setTimeout(function () { inputActive = false; }, 80);
}, { passive: false });
}
wireReal();
// 자동 루프: 주기적으로 강하게 튕겨 마찰이 기준 속도로 되돌리는 과정을 반복 재현한다.
var autoTimer = null, phase = 0;
function autoTick() {
phase += 1;
var dir = (phase % 2 === 0) ? 1 : -1;
inputActive = true;
vel = dir * 7 + BASE_SPEED;
ensureLoop();
setTimeout(function () { inputActive = false; }, 140);
}
function stopAutoplay() { if (autoTimer) { clearInterval(autoTimer); autoTimer = null; } }
if (!reduce) { autoTimer = setInterval(autoTick, 1500); autoTick(); }
})();import { useEffect, useRef } from "react";
import styles from "./ScrollMomentumFrictionMarqueeDrag.module.scss";
export type ScrollMomentumFrictionMarqueeDragProps = {
color?: string;
};
export default function ScrollMomentumFrictionMarqueeDrag({
color = "#4cd4a6",
}: ScrollMomentumFrictionMarqueeDragProps) {
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;
// 계산 겹: 평소엔 BASE_SPEED 로 계속 흐르다가, 드래그·휠로 속도(vel)가 그 값에서 벗어나면
// 매 프레임 FRICTION_PULL 만큼만 BASE_SPEED 쪽으로 되돌아온다 — 순간 정지가 아니라 서서히 복귀.
const CHIP_W = 56, GAP = 10, SET_COUNT = 6;
const WRAP = SET_COUNT * (CHIP_W + GAP);
const BASE_SPEED = -0.9;
const FRICTION_PULL = 0.06;
let pos = 0, vel = BASE_SPEED;
let inputActive = false;
let raf: number | null = null;
function clampWrap(v: number) { let m = v % WRAP; if (m > 0) m -= WRAP; return m; }
function paint() { track!.style.transform = "translateX(" + pos.toFixed(1) + "px)"; }
function loopTick() {
if (reduce) { raf = null; return; }
if (!inputActive) { vel += (BASE_SPEED - vel) * FRICTION_PULL; }
pos = clampWrap(pos + vel);
paint();
raf = requestAnimationFrame(loopTick);
}
function ensureLoop() { if (raf === null && !reduce) raf = requestAnimationFrame(loopTick); }
ensureLoop();
paint();
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 dx = e.clientX - lastX;
vel = (dx / dt) * 16.6;
pos = clampWrap(pos + dx);
paint();
lastX = e.clientX; lastT = t;
}
function onPointerUp() {
if (!dragging) return;
dragging = false; inputActive = false;
ensureLoop();
}
function onWheel(e: WheelEvent) {
e.preventDefault();
stopAutoplay();
inputActive = true;
vel += -e.deltaY * 0.4 - e.deltaX * 0.4;
ensureLoop();
if (wheelTimer !== null) window.clearTimeout(wheelTimer);
wheelTimer = window.setTimeout(() => { inputActive = false; }, 80);
}
let wheelTimer: number | null = null;
viewport.addEventListener("pointerdown", onPointerDown);
viewport.addEventListener("pointermove", onPointerMove);
viewport.addEventListener("pointerup", onPointerUp);
viewport.addEventListener("wheel", onWheel, { passive: false });
let autoTimer: number | null = null, phase = 0;
function autoTick() {
phase += 1;
const dir = phase % 2 === 0 ? 1 : -1;
inputActive = true;
vel = dir * 7 + BASE_SPEED;
ensureLoop();
window.setTimeout(() => { inputActive = false; }, 140);
}
function stopAutoplay() { if (autoTimer !== null) { window.clearInterval(autoTimer); autoTimer = null; } }
if (!reduce) { autoTimer = window.setInterval(autoTick, 1500); autoTick(); }
return () => {
stopAutoplay();
if (raf !== null) cancelAnimationFrame(raf);
viewport.removeEventListener("pointerdown", onPointerDown);
viewport.removeEventListener("pointermove", onPointerMove);
viewport.removeEventListener("pointerup", onPointerUp);
viewport.removeEventListener("wheel", onWheel);
};
}, []);
const labels = ["01", "02", "03", "04", "05", "06", "01", "02", "03", "04", "05", "06"];
const cls = ["a", "b", "c"];
return (
<div ref={rootRef} className={styles.stage}>
<div className={styles.fmd}>
<div className={styles.viewport} role="application" aria-label="마퀴 드래그 실험판" tabIndex={0}>
<div className={styles.track}>
{labels.map((label, i) => (
<span
key={i}
className={`${styles.chip} ${styles[cls[i % 3]]}`}
style={i === 0 ? { background: color } : undefined}
>
{label}
</span>
))}
</div>
</div>
<p className={styles.hint}>드래그하거나 휠을 굴리면 속도가 바뀌었다가 마찰로 기준 속도로 되돌아옵니다</p>
</div>
</div>
);
}07 Friction decel — once the input value stops, a speed value slows gradually by a friction value — Measured area 5.444% · 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
Scroll05 Idle settle — once scrolling stops, particles fall under a gravity value and settle slowly downwardMoving Particles Background CSS: 9 Scroll Scenes
Scroll01 A Card Track Where Vertical Wheel Input Becomes Sideways Motion and Friction Slows It After Input StopsHorizontal Scroll Animation CSS: 9 Inertia Scenes
Scroll07 Variable-speed flow9 CSS Marquee Infinite Loop — No JS, Scroll-Driven