07摩擦減速 — 入力の値が途切れた後、速度の値が摩擦の分だけ徐々に落ちていく方式
入力が途切れると速度の値が一定の摩擦の値ずつ落ち、やがて元の速度の値に戻ります。
使いどころロゴマーキー、ニュースティッカーバナー
BASE_SPEEDFRICTION_PULLCHIP_W

<main class="stage">
<div class="fmd is-demo" id="fmd">
<div class="fmd__viewport" id="fmdViewport" role="application" aria-label="マーキードラッグ実験パネル" 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">ドラッグまたはホイールを回すと速度が変わり、摩擦で基準速度に戻ります</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 摩擦減速 — 入力の値が途切れた後、速度の値が摩擦の分だけ徐々に落ちていく方式 — 計測 累積面積 5.444% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · スクロール 慣性 css 9選 — 値が自ら収まっていく9つの計算パターン
このデモが属する記事
スクロール 慣性 css 9選 — 値が自ら収まっていく9つの計算パターン9つのうち1つはスクロールが止まった後も移動の値が直前の速度を引き継いで少しずつ小さくなり、1つは端を超えて伸びた値が離すと元の値へ弾むように戻り、もう1つは方向が変わった瞬間に値が逆に引かれてから元の値へ戻ります。「スクロール 慣性 cs この記事のデモをすべて見る


