09Inertia scrub
Flick the row and let go: it glides on the velocity of the release, friction brings it to rest on a card. pointerup catches the release moment and the deceleration curve is computed every frame.
Best forA gallery carousel, the horizontal swipe of a story feed
requestAnimationFramepointeruptranslate

<svg width="0" height="0" style="position:absolute" aria-hidden="true">
<defs>
<symbol id="ico-swipe" viewBox="0 0 256 256" fill="currentColor"><g><path d="M208 140v36c0 24-8 40-8 40H64l-29.32-50a20 20 0 0 1 34.64-20L88 176V68a20 20 0 0 1 40 0v56a20 20 0 0 1 40 0v16a20 20 0 0 1 40 0" opacity=".2"/><path d="M216 140v36c0 25.59-8.49 42.85-8.85 43.58A8 8 0 0 1 200 224a7.9 7.9 0 0 1-3.57-.85a8 8 0 0 1-3.58-10.73c.06-.12 7.16-14.81 7.16-36.42v-36a12 12 0 0 0-24 0v4a8 8 0 0 1-16 0v-20a12 12 0 0 0-24 0v12a8 8 0 0 1-16 0V68a12 12 0 0 0-24 0v108a8 8 0 0 1-14.79 4.23l-18.68-30l-.14-.23a12 12 0 1 0-20.8 12l29.29 50a8 8 0 1 1-13.81 8l-29.32-50a28 28 0 0 1 48.41-28.17L80 148V68a28 28 0 0 1 56 0v30.7a28 28 0 0 1 38.65 16.69A28 28 0 0 1 216 140m33-92h-52.69l18.34-18.34a8 8 0 0 0-11.31-11.32l-32 32a8 8 0 0 0 0 11.32l32 32a8 8 0 0 0 11.31-11.32L195.31 64H248a8 8 0 0 0 0-16Z"/></g></symbol>
<symbol id="ico-reset" viewBox="0 0 256 256" fill="currentColor"><g><path d="M232 136a104 104 0 1 1-38.17-80.55L208 60v-4a8 8 0 0 1 16 0v34a8 8 0 0 1-8 8h-34a8 8 0 0 1 0-16h7.6l-8.89-7.4A88 88 0 1 0 216 136a8 8 0 0 1 16 0Z" opacity=".2"/><path d="M232 136a104 104 0 1 1-38.17-80.55L208 60v-4a8 8 0 0 1 16 0v34a8 8 0 0 1-8 8h-34a8 8 0 0 1 0-16h7.6l-8.89-7.4A88 88 0 1 0 216 136a8 8 0 0 1 16 0Z"/></g></symbol>
</defs>
</svg>
<main class="stage">
<div class="sc is-demo" id="sc">
<p class="sc__hint"><svg class="sc__ico" aria-hidden="true"><use href="#ico-swipe"/></svg><span>Flick it hard and let go, and it glides at the speed of the release until it settles on a card</span></p>
<div class="sc__viewport" id="viewport" role="application" aria-label="Card scrub" tabindex="0">
<div class="sc__track" id="track">
<span class="sc__card"><b>1</b><i>Card one</i></span>
<span class="sc__card"><b>2</b><i>Card two</i></span>
<span class="sc__card"><b>3</b><i>Card three</i></span>
<span class="sc__card"><b>4</b><i>Card four</i></span>
<span class="sc__card"><b>5</b><i>Card five</i></span>
<span class="sc__card"><b>6</b><i>Card six</i></span>
</div>
</div>
<div class="sc__bar">
<p class="sc__meter"><span class="sc__meterLabel">Throw speed</span><b class="sc__meterBar"><i class="sc__meterFill" id="meter"></i></b></p>
<button class="sc__reset" id="reset" type="button">
<svg class="sc__ico" aria-hidden="true"><use href="#ico-reset"/></svg><span>Reset</span>
</button>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 관성 스크럽 — pointerup 이 잡은 놓는 속도로 미끄러지고, 마찰은 60fps 기준 0.91^dt 지수 감쇠,
// 속도가 160px/s 아래로 떨어지면 가장 가까운 칸으로 스프링(150, 감쇠 13)이 붙인다.
// 트랙은 translate 하나로만 움직인다 — 진짜 스크롤(scrollLeft) 은 rAF 로 잴 수 없다 (050-06 원칙과 같은 계열).
// 격자 미리보기(.is-demo)는 훑고 놓아 감속하다 멎고 되돌아오는 한 사이클을 키프레임으로 재생한다.
$duration: $dur-loop;
$easing: ease-out;
$color: $stage-yellow;
@include stage($stage-ink);
.sc { display: flex; flex-direction: column; gap: $sp-3; width: 100%; max-width: 424px; }
.sc__hint {
display: flex; align-items: center; gap: $sp-1; margin: 0;
font-weight: 700; font-size: clamp(10px, 2.8vw, 12px); color: $subject-cream;
}
.sc__ico { width: 16px; height: 16px; flex: 0 0 auto; color: $color; }
.sc__viewport {
position: relative; height: 152px; border-radius: $r-card; overflow: hidden;
background: rgba($subject-cream, .05);
box-shadow: inset 0 0 0 1px rgba($subject-cream, .12);
cursor: grab; touch-action: pan-y;
}
.sc__viewport:active { cursor: grabbing; }
.sc__viewport:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
.sc__track {
position: absolute; left: $sp-2; top: $sp-2;
display: flex; gap: 12px; will-change: transform;
}
.sc__card {
flex: 0 0 120px; width: 120px; height: 120px;
display: grid; place-content: center; justify-items: center; gap: $sp-1;
border-radius: $r-card; background: $subject-cream; color: $stage-ink;
}
.sc__card b { font-weight: 800; font-size: 24px; line-height: 1; }
.sc__card i { font-style: normal; font-weight: 700; font-size: clamp(10px, 2.6vw, 11px); }
.sc__bar { display: flex; align-items: center; justify-content: space-between; gap: $sp-2; flex-wrap: wrap; }
.sc__meter { margin: 0; display: flex; align-items: center; gap: $sp-1; }
.sc__meterLabel { font-weight: 700; font-size: clamp(10px, 2.6vw, 11px); color: rgba($subject-cream, .82); }
.sc__meterBar {
display: block; width: 96px; height: 6px; border-radius: $r-pill; overflow: hidden;
background: rgba($subject-cream, .16);
}
// 속도 계기 — 채움 막대라 시작 변(left) 이 원점이다 (blog-demo 규칙 8 "붙는 지점" 예외)
.sc__meterFill {
display: block; width: 100%; height: 100%; border-radius: inherit;
background: $color; transform-origin: left center;
transform: scaleX(0);
}
.sc__reset {
display: inline-flex; align-items: center; gap: $sp-1; border: 0; border-radius: $r-pill;
padding: $sp-1 $sp-3; background: $color; color: $stage-ink; cursor: pointer;
font-family: inherit; font-weight: 800; font-size: clamp(10px, 2.6vw, 11px); line-height: 1;
}
.sc__reset .sc__ico { color: $stage-ink; }
// ── 격자 미리보기 루프 ─────────────────────────────────────────────
// 트랙: 0 → -264px(두 칸) 까지 ease-out 으로 감속 미끄러짐(0~48%) → 멈춤(48~64%) →
// 스프링 되돌림(64~92%) → 정지(100%). 시작과 끝이 0px 라 경계 순간이동이 없다.
.sc.is-demo .sc__track {
animation-name: scGlide; animation-duration: $duration; animation-timing-function: $easing;
animation-iteration-count: infinite;
}
@keyframes scGlide {
0% { transform: translate(0px, 0); }
48% { transform: translate(-264px, 0); }
64% { transform: translate(-264px, 0); }
92% { transform: translate(0px, 0); }
100% { transform: translate(0px, 0); }
}
// 속도 계기 — 놓는 순간 1 에서 0 으로 줄고, 되돌림 때 잠깐 반대 방향으로 찍힌 다음 0 이 된다
.sc.is-demo .sc__meterFill {
animation-name: scMeter; animation-duration: $duration; animation-timing-function: $easing;
animation-iteration-count: infinite;
}
@keyframes scMeter {
0% { transform: scaleX(1); }
48% { transform: scaleX(0); }
64% { transform: scaleX(0); }
78% { transform: scaleX(.4); }
92% { transform: scaleX(0); }
100% { transform: scaleX(0); }
}
@media (max-width: 340px) {
.sc { gap: $sp-2; }
.sc__viewport { height: 112px; }
.sc__track { left: $sp-1; top: $sp-1; gap: 8px; }
.sc__card { flex: 0 0 92px; width: 92px; height: 92px; }
.sc__card b { font-size: 20px; }
.sc__meterBar { width: 64px; }
.sc__hint { font-size: 10px; line-height: 1.25; }
}
// reduce 에서는 첫 칸에 멈춘 정지 상태 한 장을 둔다
@media (prefers-reduced-motion: reduce) {
.sc.is-demo .sc__track,
.sc.is-demo .sc__meterFill { animation: none !important; }
.sc.is-demo .sc__track { transform: translate(0, 0) !important; }
.sc.is-demo .sc__meterFill { transform: scaleX(0) !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);
}
.sc {
display: flex;
flex-direction: column;
gap: 12px;
width: 100%;
max-width: 424px;
}
.sc__hint {
display: flex;
align-items: center;
gap: 4px;
margin: 0;
font-weight: 700;
font-size: clamp(10px, 2.8vw, 12px);
color: #fff7e6;
}
.sc__ico {
width: 16px;
height: 16px;
flex: 0 0 auto;
color: #ffd23f;
}
.sc__viewport {
position: relative;
height: 152px;
border-radius: 16px;
overflow: hidden;
background: rgba(255, 247, 230, 0.05);
box-shadow: inset 0 0 0 1px rgba(255, 247, 230, 0.12);
cursor: grab;
touch-action: pan-y;
}
.sc__viewport:active {
cursor: grabbing;
}
.sc__viewport:focus-visible {
outline: 2px solid #ffd23f;
outline-offset: 2px;
}
.sc__track {
position: absolute;
left: 8px;
top: 8px;
display: flex;
gap: 12px;
will-change: transform;
}
.sc__card {
flex: 0 0 120px;
width: 120px;
height: 120px;
display: grid;
place-content: center;
justify-items: center;
gap: 4px;
border-radius: 16px;
background: #fff7e6;
color: #17141a;
}
.sc__card b {
font-weight: 800;
font-size: 24px;
line-height: 1;
}
.sc__card i {
font-style: normal;
font-weight: 700;
font-size: clamp(10px, 2.6vw, 11px);
}
.sc__bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
flex-wrap: wrap;
}
.sc__meter {
margin: 0;
display: flex;
align-items: center;
gap: 4px;
}
.sc__meterLabel {
font-weight: 700;
font-size: clamp(10px, 2.6vw, 11px);
color: rgba(255, 247, 230, 0.82);
}
.sc__meterBar {
display: block;
width: 96px;
height: 6px;
border-radius: 999px;
overflow: hidden;
background: rgba(255, 247, 230, 0.16);
}
.sc__meterFill {
display: block;
width: 100%;
height: 100%;
border-radius: inherit;
background: #ffd23f;
transform-origin: left center;
transform: scaleX(0);
}
.sc__reset {
display: inline-flex;
align-items: center;
gap: 4px;
border: 0;
border-radius: 999px;
padding: 4px 12px;
background: #ffd23f;
color: #17141a;
cursor: pointer;
font-family: inherit;
font-weight: 800;
font-size: clamp(10px, 2.6vw, 11px);
line-height: 1;
}
.sc__reset .sc__ico {
color: #17141a;
}
.sc.is-demo .sc__track {
animation-name: scGlide;
animation-duration: 2s;
animation-timing-function: ease-out;
animation-iteration-count: infinite;
}
@keyframes scGlide {
0% {
transform: translate(0px, 0);
}
48% {
transform: translate(-264px, 0);
}
64% {
transform: translate(-264px, 0);
}
92% {
transform: translate(0px, 0);
}
100% {
transform: translate(0px, 0);
}
}
.sc.is-demo .sc__meterFill {
animation-name: scMeter;
animation-duration: 2s;
animation-timing-function: ease-out;
animation-iteration-count: infinite;
}
@keyframes scMeter {
0% {
transform: scaleX(1);
}
48% {
transform: scaleX(0);
}
64% {
transform: scaleX(0);
}
78% {
transform: scaleX(0.4);
}
92% {
transform: scaleX(0);
}
100% {
transform: scaleX(0);
}
}
@media (max-width: 340px) {
.sc {
gap: 8px;
}
.sc__viewport {
height: 112px;
}
.sc__track {
left: 4px;
top: 4px;
gap: 8px;
}
.sc__card {
flex: 0 0 92px;
width: 92px;
height: 92px;
}
.sc__card b {
font-size: 20px;
}
.sc__meterBar {
width: 64px;
}
.sc__hint {
font-size: 10px;
line-height: 1.25;
}
}
@media (prefers-reduced-motion: reduce) {
.sc.is-demo .sc__track,
.sc.is-demo .sc__meterFill {
animation: none !important;
}
.sc.is-demo .sc__track {
transform: translate(0, 0) !important;
}
.sc.is-demo .sc__meterFill {
transform: scaleX(0) !important;
}
}var root = document.getElementById('sc');
var viewport = document.getElementById('viewport');
var track = document.getElementById('track');
var meter = document.getElementById('meter');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)');
var CW = 120, GAP2 = 12, STEP = CW + GAP2; // 카드 120px + 간격 12px = 한 칸 132px
var x = 0, vx = 0, dragging = false, samples = [], last = 0, running = false, settling = false;
function clampX(v) {
var max = 0, min = viewport.clientWidth - track.scrollWidth;
return Math.max(min, Math.min(max, v));
}
function snapTarget() { return Math.round(clampX(x) / STEP) * STEP; }
function paint() {
track.style.transform = 'translate(' + x.toFixed(1) + 'px, 0)';
var pct = Math.min(1, Math.abs(vx) / 900);
meter.style.transform = 'scaleX(' + pct.toFixed(3) + ')';
}
function step(now) {
var dt = Math.min((now - last) / 1000, 0.033);
last = now;
// 손이 붙어 있는 동안은 물리를 돌리지 않는다 — 돌리면 마찰 감속과 스냅 스프링이 끄는 손과 싸운다
if (dragging) { requestAnimationFrame(step); return; }
if (settling) {
// 카드에 붙는 마지막 스프링 — 목표는 가장 가까운 칸
var target = snapTarget();
vx += (150 * (target - x) - 13 * vx) * dt;
x += vx * dt;
if (Math.abs(target - x) < 0.4 && Math.abs(vx) < 6) {
x = target; vx = 0; settling = false; paint(); running = false; return;
}
} else {
// 마찰 감속 — 프레임 비율과 무관하게 60fps 기준 0.91^dt 로 지수 감쇠시킨다
vx *= Math.pow(0.91, dt * 60);
x += vx * dt;
var c = clampX(x);
if (c !== x) { x = c; vx = 0; }
if (Math.abs(vx) < 160) { settling = true; vx = 0; }
}
paint();
requestAnimationFrame(step);
}
function wake() {
root.classList.remove('is-demo');
if (reduce.matches) { x = snapTarget(); vx = 0; paint(); return false; }
if (!running) { running = true; last = performance.now(); requestAnimationFrame(step); }
return true;
}
viewport.addEventListener('pointerdown', function (e) {
e.preventDefault();
dragging = true; settling = false;
x = clampX(x); vx = 0;
samples = [{ x: e.clientX, t: performance.now() }];
viewport.setPointerCapture(e.pointerId);
wake();
});
viewport.addEventListener('pointermove', function (e) {
if (!dragging) { return; }
var now = performance.now();
var prev = samples[samples.length - 1];
x = clampX(x + (e.clientX - prev.x));
samples.push({ x: e.clientX, t: now });
if (samples.length > 8) { samples.shift(); }
paint();
});
viewport.addEventListener('pointerup', function () {
if (!dragging) { return; }
dragging = false;
if (reduce.matches) { x = snapTarget(); vx = 0; paint(); return; }
// 놓는 순간의 속도 = 최근 표본들의 기울기 (pointerup 이 순간을 잡는다)
var now = performance.now(), i = samples.length - 1;
while (i > 0 && now - samples[i - 1].t < 120) { i -= 1; }
var span = samples[samples.length - 1].t - samples[i].t;
vx = span > 8 ? (samples[samples.length - 1].x - samples[i].x) / span * 1000 : 0;
vx = Math.max(-1400, Math.min(1400, vx));
settling = false;
if (!running) { wake(); }
});
viewport.addEventListener('keydown', function (e) {
if (e.key === 'ArrowLeft') { x = clampX(x + STEP); vx = 0; settling = true; wake(); }
else if (e.key === 'ArrowRight') { x = clampX(x - STEP); vx = 0; settling = true; wake(); }
else { return; }
e.preventDefault();
});
document.getElementById('reset').addEventListener('click', function () { x = 0; vx = 0; settling = false; paint(); wake(); });
window.addEventListener('resize', function () { x = clampX(x); paint(); });
paint();// InertiaScrub — vanilla 판(run/292/demo/09-physics-inertia-scrub/index.html) 의 React 포트.
// pointerup 이 잡은 놓는 속도로 미끄러지고, 마찰은 60fps 기준 0.91^dt 지수 감쇠,
// 속도가 160px/s 아래로 떨어지면 가장 가까운 칸으로 스프링(150, 감쇠 13)이 붙인다.
// 트랙은 translate 하나로만 움직인다 — 진짜 스크롤(scrollLeft) 은 rAF 로 잴 수 없다.
// 상수·식은 vanilla 와 같은 값이다. 격자 미리보기(isDemo)는 module.scss 의 키프레임이 돌린다.
import { useEffect, useRef, useState } from "react";
import type { CSSProperties, PointerEvent as ReactPointerEvent } from "react";
import styles from "./InertiaScrub.module.scss";
export type InertiaScrubProps = {
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
// useRef 는 T | null 을 돌려준다. 처음부터 값이 있고 절대 비지 않는 상자는 이렇게 좁혀 쓴다
type Box<T> = { current: T };
// React 의 KeyboardEvent 제네릭이 없는 환경이라 필요한 것만 로컬로 선언한다
type KeyEvt = { key: string; preventDefault(): void };
type Sample = { x: number; t: number };
const CW = 120;
const GAP2 = 12;
const STEP = CW + GAP2; // 카드 120px + 간격 12px = 한 칸 132px
const HINT = "세게 훑고 놓으면 손 뗀 속도로 미끄러지다 카드에 붙습니다";
const VIEW_LABEL = "카드 스크럽";
const METER_LABEL = "던진 속도";
const RESET_LABEL = "처음으로";
const CARDS = [
{ n: "1", name: "첫 카드" },
{ n: "2", name: "둘 카드" },
{ n: "3", name: "셋 카드" },
{ n: "4", name: "넷 카드" },
{ n: "5", name: "다섯 카드" },
{ n: "6", name: "여섯 카드" },
];
function SwipeIcon({ className }: { className: string }) {
return (
<svg viewBox="0 0 256 256" className={className} aria-hidden="true" fill="currentColor"><g><path d="M208 140v36c0 24-8 40-8 40H64l-29.32-50a20 20 0 0 1 34.64-20L88 176V68a20 20 0 0 1 40 0v56a20 20 0 0 1 40 0v16a20 20 0 0 1 40 0" opacity=".2"/><path d="M216 140v36c0 25.59-8.49 42.85-8.85 43.58A8 8 0 0 1 200 224a7.9 7.9 0 0 1-3.57-.85a8 8 0 0 1-3.58-10.73c.06-.12 7.16-14.81 7.16-36.42v-36a12 12 0 0 0-24 0v4a8 8 0 0 1-16 0v-20a12 12 0 0 0-24 0v12a8 8 0 0 1-16 0V68a12 12 0 0 0-24 0v108a8 8 0 0 1-14.79 4.23l-18.68-30l-.14-.23a12 12 0 1 0-20.8 12l29.29 50a8 8 0 1 1-13.81 8l-29.32-50a28 28 0 0 1 48.41-28.17L80 148V68a28 28 0 0 1 56 0v30.7a28 28 0 0 1 38.65 16.69A28 28 0 0 1 216 140m33-92h-52.69l18.34-18.34a8 8 0 0 0-11.31-11.32l-32 32a8 8 0 0 0 0 11.32l32 32a8 8 0 0 0 11.31-11.32L195.31 64H248a8 8 0 0 0 0-16Z"/></g></svg>
);
}
function ResetIcon({ className }: { className: string }) {
return (
<svg viewBox="0 0 256 256" className={className} aria-hidden="true" fill="currentColor"><g><path d="M232 136a104 104 0 1 1-38.17-80.55L208 60v-4a8 8 0 0 1 16 0v34a8 8 0 0 1-8 8h-34a8 8 0 0 1 0-16h7.6l-8.89-7.4A88 88 0 1 0 216 136a8 8 0 0 1 16 0Z" opacity=".2"/><path d="M232 136a104 104 0 1 1-38.17-80.55L208 60v-4a8 8 0 0 1 16 0v34a8 8 0 0 1-8 8h-34a8 8 0 0 1 0-16h7.6l-8.89-7.4A88 88 0 1 0 216 136a8 8 0 0 1 16 0Z"/></g></svg>
);
}
export default function InertiaScrub({
duration = 2000,
easing = "ease-out",
color = "#ffd23f",
}: InertiaScrubProps) {
const viewportRef = useRef<HTMLDivElement | null>(null);
const trackRef = useRef<HTMLDivElement | null>(null);
const meterRef = useRef<HTMLElement | null>(null);
const xBox = useRef(0) as Box<number>;
const vxBox = useRef(0) as Box<number>;
const draggingBox = useRef(false) as Box<boolean>;
const settlingBox = useRef(false) as Box<boolean>;
const samplesBox = useRef<Sample[]>([]) as Box<Sample[]>;
const lastBox = useRef(0) as Box<number>;
const runningBox = useRef(false) as Box<boolean>;
const frameBox = useRef(0) as Box<number>;
const [demo, setDemo] = useState(true);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
function reduced(): boolean {
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
function clampX(v: number): number {
const viewport = viewportRef.current;
const track = trackRef.current;
if (!viewport || !track) { return v; }
const max = 0;
const min = viewport.clientWidth - track.scrollWidth;
return Math.max(min, Math.min(max, v));
}
function snapTarget(): number {
return Math.round(clampX(xBox.current) / STEP) * STEP;
}
function paint(): void {
const track = trackRef.current;
if (track) {
track.style.transform = "translate(" + xBox.current.toFixed(1) + "px, 0)";
}
const meter = meterRef.current;
if (meter) {
const pct = Math.min(1, Math.abs(vxBox.current) / 900);
meter.style.transform = "scaleX(" + pct.toFixed(3) + ")";
}
}
function step(now: number): void {
const dt = Math.min((now - lastBox.current) / 1000, 0.033);
lastBox.current = now;
// 손이 붙어 있는 동안은 물리를 돌리지 않는다 — 돌리면 마찰 감속과 스냅 스프링이 끄는 손과 싸운다
if (draggingBox.current) { frameBox.current = requestAnimationFrame(step); return; }
if (settlingBox.current) {
// 카드에 붙는 마지막 스프링 — 목표는 가장 가까운 칸
const target = snapTarget();
vxBox.current += (150 * (target - xBox.current) - 13 * vxBox.current) * dt;
xBox.current += vxBox.current * dt;
if (Math.abs(target - xBox.current) < 0.4 && Math.abs(vxBox.current) < 6) {
xBox.current = target;
vxBox.current = 0;
settlingBox.current = false;
paint();
runningBox.current = false;
return;
}
} else {
// 마찰 감속 — 프레임 비율과 무관하게 60fps 기준 0.91^dt 로 지수 감쇠시킨다
vxBox.current *= Math.pow(0.91, dt * 60);
xBox.current += vxBox.current * dt;
const c = clampX(xBox.current);
if (c !== xBox.current) { xBox.current = c; vxBox.current = 0; }
if (Math.abs(vxBox.current) < 160) { settlingBox.current = true; vxBox.current = 0; }
}
paint();
frameBox.current = requestAnimationFrame(step);
}
function wake(): boolean {
setDemo(false);
if (reduced()) { xBox.current = snapTarget(); vxBox.current = 0; paint(); return false; }
if (!runningBox.current) {
runningBox.current = true;
lastBox.current = performance.now();
frameBox.current = requestAnimationFrame(step);
}
return true;
}
function onPointerDown(event: ReactPointerEvent<HTMLDivElement>): void {
event.preventDefault();
draggingBox.current = true;
settlingBox.current = false;
xBox.current = clampX(xBox.current);
vxBox.current = 0;
samplesBox.current = [{ x: event.clientX, t: performance.now() }];
const viewport = viewportRef.current;
if (viewport) { viewport.setPointerCapture(event.pointerId); }
wake();
}
function onPointerMove(event: ReactPointerEvent<HTMLDivElement>): void {
if (!draggingBox.current) { return; }
const now = performance.now();
const samples = samplesBox.current;
const prev = samples[samples.length - 1];
xBox.current = clampX(xBox.current + (event.clientX - prev.x));
samples.push({ x: event.clientX, t: now });
if (samples.length > 8) { samples.shift(); }
paint();
}
function onPointerUp(): void {
if (!draggingBox.current) { return; }
draggingBox.current = false;
if (reduced()) { xBox.current = snapTarget(); vxBox.current = 0; paint(); return; }
// 놓는 순간의 속도 = 최근 표본들의 기울기 (pointerup 이 순간을 잡는다)
const now = performance.now();
const samples = samplesBox.current;
let i = samples.length - 1;
while (i > 0 && now - samples[i - 1].t < 120) { i -= 1; }
const span = samples[samples.length - 1].t - samples[i].t;
vxBox.current = span > 8 ? ((samples[samples.length - 1].x - samples[i].x) / span) * 1000 : 0;
vxBox.current = Math.max(-1400, Math.min(1400, vxBox.current));
settlingBox.current = false;
if (!runningBox.current) { wake(); }
}
function onKeyDown(event: KeyEvt): void {
if (event.key === "ArrowLeft") {
xBox.current = clampX(xBox.current + STEP);
vxBox.current = 0;
settlingBox.current = true;
wake();
} else if (event.key === "ArrowRight") {
xBox.current = clampX(xBox.current - STEP);
vxBox.current = 0;
settlingBox.current = true;
wake();
} else { return; }
event.preventDefault();
}
function onReset(): void {
xBox.current = 0;
vxBox.current = 0;
settlingBox.current = false;
paint();
wake();
}
useEffect(() => {
paint();
const onResize = (): void => { xBox.current = clampX(xBox.current); paint(); };
window.addEventListener("resize", onResize);
return () => {
window.removeEventListener("resize", onResize);
cancelAnimationFrame(frameBox.current);
};
}, []);
const cls = [styles.root, demo ? styles.isDemo : ""].filter(Boolean).join(" ");
return (
<main className={styles.stage}>
<div className={cls} style={vars}>
<p className={styles.hint}><SwipeIcon className={styles.ico} /><span>{HINT}</span></p>
<div
className={styles.viewport}
ref={viewportRef}
role="application"
aria-label={VIEW_LABEL}
tabIndex={0}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onKeyDown={onKeyDown}
>
<div className={styles.track} ref={trackRef}>
{CARDS.map((card) => (
<span className={styles.card} key={card.n}><b>{card.n}</b><i>{card.name}</i></span>
))}
</div>
</div>
<div className={styles.bar}>
<p className={styles.meter}>
<span className={styles.meterLabel}>{METER_LABEL}</span>
<b className={styles.meterBar}>
<i className={styles.meterFill} ref={meterRef} />
</b>
</p>
<button className={styles.reset} type="button" onClick={onReset}>
<ResetIcon className={styles.ico} /><span>{RESET_LABEL}</span>
</button>
</div>
</div>
</main>
);
}09 Inertia scrub — Measured area 33.269% · moving frames 20/23Original by GODRICH · SCSS and React ship in the zip · JavaScript Physics Simulation: 9 Demos, No Library
The post this demo lives in
JavaScript Physics Simulation: 9 Demos, No LibraryA JavaScript physics simulation is arithmetic repeated once per frame: force adds to velocity, velocity adds to position See all demos in this post


