06놓는 속도가 남은 거리를 미는 관성 플링 드래그
손을 떼기 직전 몇 프레임의 이동량으로 속도 값을 계산해, 그 속도로 이동이 계속되다가 감속 계수만큼씩 매 프레임 줄어들어 자연스럽게 멈춘다.
쓰임보드판 위 카드 자유 배치
FLING_DECAY_RATEKEY_IMPULSE_PX_SflingTick

<main class="stage">
<div class="fl is-demo" id="fl">
<div class="fl__box" id="flBox">
<button type="button" class="fl__ball" id="flBall" aria-label="던지면 벽에 튕기며 서서히 멈추는 공. Enter 로 잡고 화살표로 방향에 힘을 줍니다"></button>
</div>
<p class="fl__caption" id="flCaption">빠르게 놓으면 그 속도로 날아가 벽에 튕겨요</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-orange);
.fl {
position: relative;
width: min(92vw, calc(160vh - 12.8vw));
max-width: 400px;
margin: 0 auto;
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-4;
}
.fl__box {
position: relative;
width: 100%;
height: 170px;
max-width: 320px;
border-radius: $r-card;
border: 2px dashed rgba($subject-cream, .3);
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.fl__ball {
appearance: none;
border: 0;
width: 46px;
height: 46px;
border-radius: $r-pill;
background: $subject-orange-deep;
box-shadow: $shadow-raised;
cursor: grab;
touch-action: none;
will-change: transform;
}
.fl.is-bounce .fl__ball {
background: $subject-cream;
}
.fl__ball.is-grabbed,
.fl__ball:focus-visible {
outline: 2px solid $subject-cream;
outline-offset: 3px;
}
.fl.is-dragging .fl__ball {
cursor: grabbing;
}
.fl__caption {
margin: 0;
color: $subject-cream;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
text-align: center;
}
@media (max-width: 340px) {
.fl__box { height: 130px; }
.fl__ball { width: 36px; height: 36px; }
.fl__caption { font-size: 10px; }
}
@media (prefers-reduced-motion: reduce) {
.fl__ball { transition: none; }
}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);
}
.fl {
position: relative;
width: min(92vw, 160vh - 12.8vw);
max-width: 400px;
margin: 0 auto;
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
}
.fl__box {
position: relative;
width: 100%;
height: 170px;
max-width: 320px;
border-radius: 16px;
border: 2px dashed rgba(255, 247, 230, 0.3);
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.fl__ball {
appearance: none;
border: 0;
width: 46px;
height: 46px;
border-radius: 999px;
background: #c93000;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
cursor: grab;
touch-action: none;
will-change: transform;
}
.fl.is-bounce .fl__ball {
background: #fff7e6;
}
.fl__ball.is-grabbed,
.fl__ball:focus-visible {
outline: 2px solid #fff7e6;
outline-offset: 3px;
}
.fl.is-dragging .fl__ball {
cursor: grabbing;
}
.fl__caption {
margin: 0;
color: #fff7e6;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
text-align: center;
}
@media (max-width: 340px) {
.fl__box {
height: 130px;
}
.fl__ball {
width: 36px;
height: 36px;
}
.fl__caption {
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.fl__ball {
transition: none;
}
}(function () {
// 기법: 드래그 중 마지막 몇 프레임의 이동량으로 놓는 순간의 속도(vx,vy)를 구하고,
// 놓으면 그 속도로 계속 이동시키며 매 프레임 FLING_DECAY_RATE 로 감쇠, 경계에
// 부딪히면 해당 축 속도의 부호를 반전(반사)시킨다.
var root = document.getElementById('fl');
var box = document.getElementById('flBox');
var ball = document.getElementById('flBall');
var caption = document.getElementById('flCaption');
var FLING_DECAY_RATE = 0.92;
var KEY_IMPULSE_PX_S = 260;
var pos = { x: 0, y: 0 };
var vel = { x: 0, y: 0 };
var pointerStart = null;
var startPos = null;
var lastSample = null;
var dragging = false;
var grabbed = false;
var raf = 0, lastTs = null;
function applyBall() {
ball.style.transform = 'translate(' + pos.x.toFixed(1) + 'px,' + pos.y.toFixed(1) + 'px)';
}
function bounds() {
var br = box.getBoundingClientRect();
var brc = ball.getBoundingClientRect();
var halfW = brc.width / 2, halfH = brc.height / 2;
return { minX: br.left + halfW, maxX: br.right - halfW, minY: br.top + halfH, maxY: br.bottom - halfH };
}
function flingTick(ts) {
if (lastTs === null) lastTs = ts;
var dt = Math.min((ts - lastTs) / 1000, 0.032);
lastTs = ts;
pos.x += vel.x * dt;
pos.y += vel.y * dt;
var b = bounds();
var origin = ballOrigin();
var cx = origin.x + pos.x, cy = origin.y + pos.y;
if (cx < b.minX) { pos.x += (b.minX - cx); vel.x = Math.abs(vel.x); root.classList.add('is-bounce'); }
else if (cx > b.maxX) { pos.x -= (cx - b.maxX); vel.x = -Math.abs(vel.x); root.classList.add('is-bounce'); }
if (cy < b.minY) { pos.y += (b.minY - cy); vel.y = Math.abs(vel.y); root.classList.add('is-bounce'); }
else if (cy > b.maxY) { pos.y -= (cy - b.maxY); vel.y = -Math.abs(vel.y); root.classList.add('is-bounce'); }
vel.x *= Math.pow(FLING_DECAY_RATE, dt * 60);
vel.y *= Math.pow(FLING_DECAY_RATE, dt * 60);
applyBall();
var speed = Math.hypot(vel.x, vel.y);
if (speed > 4) { raf = requestAnimationFrame(flingTick); return; }
vel.x = 0; vel.y = 0;
root.classList.remove('is-bounce');
lastTs = null;
}
var originCache = null;
function ballOrigin() {
if (originCache) return originCache;
var r = ball.getBoundingClientRect();
originCache = { x: r.left + r.width / 2 - pos.x, y: r.top + r.height / 2 - pos.y };
return originCache;
}
function beginDrag(clientX, clientY) {
cancelAnimationFrame(raf);
dragging = true;
vel.x = 0; vel.y = 0;
pointerStart = { x: clientX, y: clientY };
startPos = { x: pos.x, y: pos.y };
lastSample = { x: clientX, y: clientY, t: performance.now() };
root.classList.add('is-dragging');
}
function moveDrag(clientX, clientY) {
pos.x = startPos.x + (clientX - pointerStart.x);
pos.y = startPos.y + (clientY - pointerStart.y);
applyBall();
var now = performance.now();
var dt = Math.max((now - lastSample.t) / 1000, 0.001);
vel.x = (clientX - lastSample.x) / dt;
vel.y = (clientY - lastSample.y) / dt;
lastSample = { x: clientX, y: clientY, t: now };
}
function endDrag() {
dragging = false;
root.classList.remove('is-dragging');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce) { vel.x = 0; vel.y = 0; }
else { lastTs = null; raf = requestAnimationFrame(flingTick); }
}
ball.addEventListener('pointerdown', function (e) {
root.classList.remove('is-demo');
ball.setPointerCapture(e.pointerId);
beginDrag(e.clientX, e.clientY);
caption.textContent = '속도를 재고 있어요. 놓으면 그대로 날아가요';
e.preventDefault();
});
ball.addEventListener('pointermove', function (e) {
if (!dragging) return;
moveDrag(e.clientX, e.clientY);
e.preventDefault();
});
ball.addEventListener('pointerup', function (e) {
if (!dragging) return;
endDrag();
caption.textContent = '빠르게 놓으면 그 속도로 날아가 벽에 튕겨요';
e.preventDefault();
});
ball.addEventListener('keydown', function (e) {
root.classList.remove('is-demo');
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
grabbed = !grabbed;
ball.classList.toggle('is-grabbed', grabbed);
caption.textContent = grabbed ? '잡았어요. 화살표로 방향에 힘을 주세요' : '빠르게 놓으면 그 속도로 날아가 벽에 튕겨요';
return;
}
if (!grabbed) return;
var ix = 0, iy = 0;
if (e.key === 'ArrowLeft') ix = -KEY_IMPULSE_PX_S;
else if (e.key === 'ArrowRight') ix = KEY_IMPULSE_PX_S;
else if (e.key === 'ArrowUp') iy = -KEY_IMPULSE_PX_S;
else if (e.key === 'ArrowDown') iy = KEY_IMPULSE_PX_S;
else if (e.key === 'Escape') {
e.preventDefault();
grabbed = false;
ball.classList.remove('is-grabbed');
caption.textContent = '빠르게 놓으면 그 속도로 날아가 벽에 튕겨요';
return;
} else { return; }
e.preventDefault();
vel.x += ix; vel.y += iy;
grabbed = false;
ball.classList.remove('is-grabbed');
lastTs = null;
cancelAnimationFrame(raf);
raf = requestAnimationFrame(flingTick);
});
applyBall();
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce) {
root.classList.remove('is-demo');
} else {
var ballRect0 = ball.getBoundingClientRect();
var ox0 = ballRect0.left + ballRect0.width / 2;
var oy0 = ballRect0.top + ballRect0.height / 2;
var demoStart = null;
function demoTick(ts) {
if (demoStart === null) demoStart = ts;
var t = ts - demoStart;
if (t < 200) {
// 대기
} else if (t < 420) {
var p = (t - 200) / 220;
var cx = ox0 - 60 * p;
var cy = oy0 - 8 * p;
if (t < 220) beginDrag(ox0, oy0);
moveDrag(cx, cy);
} else if (t < 460) {
endDrag();
} else {
if (t > 1900) demoStart = ts - 60;
}
requestAnimationFrame(demoTick);
}
requestAnimationFrame(demoTick);
}
})();import { useEffect, useRef } from "react";
import styles from "./DragPhysMomentumFling.module.scss";
type Box<T> = { current: T };
type Pt = { x: number; y: number };
export default function DragPhysMomentumFling() {
const rootRef = useRef<HTMLDivElement>(null);
const boxRef = useRef<HTMLDivElement>(null);
const ballRef = useRef<HTMLButtonElement>(null);
const captionRef = useRef<HTMLParagraphElement>(null);
useEffect(() => {
const root = rootRef.current;
const box = boxRef.current;
const ball = ballRef.current;
const caption = captionRef.current;
if (!root || !box || !ball || !caption) return;
const FLING_DECAY_RATE = 0.92;
const KEY_IMPULSE_PX_S = 260;
const pos: Pt = { x: 0, y: 0 };
const vel: Pt = { x: 0, y: 0 };
let pointerStart: Pt | null = null;
let startPos: Pt = { x: 0, y: 0 };
let lastSample: { x: number; y: number; t: number } | null = null;
let dragging = false;
let grabbed = false;
let raf = 0, lastTs: number | null = null;
let originCache: Pt | null = null;
function applyBall() {
ball!.style.transform = "translate(" + pos.x.toFixed(1) + "px," + pos.y.toFixed(1) + "px)";
}
function bounds() {
const br = box!.getBoundingClientRect();
const brc = ball!.getBoundingClientRect();
const halfW = brc.width / 2, halfH = brc.height / 2;
return { minX: br.left + halfW, maxX: br.right - halfW, minY: br.top + halfH, maxY: br.bottom - halfH };
}
function ballOrigin() {
if (originCache) return originCache;
const r = ball!.getBoundingClientRect();
originCache = { x: r.left + r.width / 2 - pos.x, y: r.top + r.height / 2 - pos.y };
return originCache;
}
function flingTick(ts: number) {
if (lastTs === null) lastTs = ts;
const dt = Math.min((ts - lastTs) / 1000, 0.032);
lastTs = ts;
pos.x += vel.x * dt;
pos.y += vel.y * dt;
const b = bounds();
const origin = ballOrigin();
const cx = origin.x + pos.x, cy = origin.y + pos.y;
if (cx < b.minX) { pos.x += b.minX - cx; vel.x = Math.abs(vel.x); root!.classList.add(styles.isBounce); }
else if (cx > b.maxX) { pos.x -= cx - b.maxX; vel.x = -Math.abs(vel.x); root!.classList.add(styles.isBounce); }
if (cy < b.minY) { pos.y += b.minY - cy; vel.y = Math.abs(vel.y); root!.classList.add(styles.isBounce); }
else if (cy > b.maxY) { pos.y -= cy - b.maxY; vel.y = -Math.abs(vel.y); root!.classList.add(styles.isBounce); }
vel.x *= Math.pow(FLING_DECAY_RATE, dt * 60);
vel.y *= Math.pow(FLING_DECAY_RATE, dt * 60);
applyBall();
const speed = Math.hypot(vel.x, vel.y);
if (speed > 4) { raf = requestAnimationFrame(flingTick); return; }
vel.x = 0; vel.y = 0;
root!.classList.remove(styles.isBounce);
lastTs = null;
}
function beginDrag(clientX: number, clientY: number) {
cancelAnimationFrame(raf);
dragging = true;
vel.x = 0; vel.y = 0;
pointerStart = { x: clientX, y: clientY };
startPos = { x: pos.x, y: pos.y };
lastSample = { x: clientX, y: clientY, t: performance.now() };
root!.classList.add(styles.isDragging);
}
function moveDrag(clientX: number, clientY: number) {
pos.x = startPos.x + (clientX - pointerStart!.x);
pos.y = startPos.y + (clientY - pointerStart!.y);
applyBall();
const now = performance.now();
const dt = Math.max((now - lastSample!.t) / 1000, 0.001);
vel.x = (clientX - lastSample!.x) / dt;
vel.y = (clientY - lastSample!.y) / dt;
lastSample = { x: clientX, y: clientY, t: now };
}
function endDrag() {
dragging = false;
root!.classList.remove(styles.isDragging);
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (reduce) { vel.x = 0; vel.y = 0; } else { lastTs = null; raf = requestAnimationFrame(flingTick); }
}
function onPointerDown(e: PointerEvent) {
root!.classList.remove("is-demo");
ball!.setPointerCapture(e.pointerId);
beginDrag(e.clientX, e.clientY);
caption!.textContent = "속도를 재고 있어요. 놓으면 그대로 날아가요";
e.preventDefault();
}
function onPointerMove(e: PointerEvent) {
if (!dragging) return;
moveDrag(e.clientX, e.clientY);
e.preventDefault();
}
function onPointerUp(e: PointerEvent) {
if (!dragging) return;
endDrag();
caption!.textContent = "빠르게 놓으면 그 속도로 날아가 벽에 튕겨요";
e.preventDefault();
}
ball.addEventListener("pointerdown", onPointerDown);
ball.addEventListener("pointermove", onPointerMove);
ball.addEventListener("pointerup", onPointerUp);
function onKeyDown(e: KeyboardEvent) {
root!.classList.remove("is-demo");
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
grabbed = !grabbed;
ball!.classList.toggle(styles.isGrabbed, grabbed);
caption!.textContent = grabbed ? "잡았어요. 화살표로 방향에 힘을 주세요" : "빠르게 놓으면 그 속도로 날아가 벽에 튕겨요";
return;
}
if (!grabbed) return;
let ix = 0, iy = 0;
if (e.key === "ArrowLeft") ix = -KEY_IMPULSE_PX_S;
else if (e.key === "ArrowRight") ix = KEY_IMPULSE_PX_S;
else if (e.key === "ArrowUp") iy = -KEY_IMPULSE_PX_S;
else if (e.key === "ArrowDown") iy = KEY_IMPULSE_PX_S;
else if (e.key === "Escape") {
e.preventDefault();
grabbed = false;
ball!.classList.remove(styles.isGrabbed);
caption!.textContent = "빠르게 놓으면 그 속도로 날아가 벽에 튕겨요";
return;
} else { return; }
e.preventDefault();
vel.x += ix; vel.y += iy;
grabbed = false;
ball!.classList.remove(styles.isGrabbed);
lastTs = null;
cancelAnimationFrame(raf);
raf = requestAnimationFrame(flingTick);
}
ball.addEventListener("keydown", onKeyDown);
applyBall();
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let demoRaf = 0;
if (reduce) {
root.classList.remove("is-demo");
} else {
const ballRect0 = ball.getBoundingClientRect();
const ox0 = ballRect0.left + ballRect0.width / 2;
const oy0 = ballRect0.top + ballRect0.height / 2;
let demoStart: number | null = null;
function demoTick(ts: number) {
if (demoStart === null) demoStart = ts;
const t = ts - demoStart;
if (t < 200) {
// 대기
} else if (t < 420) {
const p = (t - 200) / 220;
const cx = ox0 - 60 * p;
const cy = oy0 - 8 * p;
if (t < 220) beginDrag(ox0, oy0);
moveDrag(cx, cy);
} else if (t < 460) {
endDrag();
} else {
if (t > 1900) demoStart = ts - 60;
}
demoRaf = requestAnimationFrame(demoTick);
}
demoRaf = requestAnimationFrame(demoTick);
}
return () => {
ball.removeEventListener("pointerdown", onPointerDown);
ball.removeEventListener("pointermove", onPointerMove);
ball.removeEventListener("pointerup", onPointerUp);
ball.removeEventListener("keydown", onKeyDown);
cancelAnimationFrame(raf);
cancelAnimationFrame(demoRaf);
};
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.fl} is-demo`} ref={rootRef}>
<div className={styles.box} ref={boxRef}>
<button
type="button"
className={styles.ball}
ref={ballRef}
aria-label="던지면 벽에 튕기며 서서히 멈추는 공. Enter 로 잡고 화살표로 방향에 힘을 줍니다"
></button>
</div>
<p className={styles.caption} ref={captionRef}>빠르게 놓으면 그 속도로 날아가 벽에 튕겨요</p>
</div>
</main>
);
}06 놓는 속도가 남은 거리를 미는 관성 플링 드래그 — 측정 누적면적 9.251% · 움직인 프레임 17/23GODRICH 원작 · zip 에 SCSS·React 포함 · 드래그 앤 드롭 구현 9가지 — 손 뗀 뒤 속도까지 잇는 계산
이 데모가 속한 글
드래그 앤 드롭 구현 9가지 — 손 뗀 뒤 속도까지 잇는 계산드래그 앤 드롭 구현이라는 말은 보통 잡고 옮기는 동작 하나를 가리키지만, 여기 아홉 개는 손을 뗀 뒤까지 계산이 이어진다. 자석 격자가 24px 안쪽에서 아이콘을 붙잡고, 고무줄처럼 늘어난 경계가 그 손을 다시 놓 이 글의 데모 전부 보기


