07Ball Thrown at the Exact Speed of Its Drag
The pointer's movement over the last few frames is measured and stored as a speed, and the instant it's released, the ball flies off at exactly that speed.
Best forDrag-to-throw card or ball interaction
SAMPLE_WINDOW_MSKEY_IMPULSEreleaseVelocity

<main class="stage">
<div class="bp is-demo" id="bpRoot" tabindex="0">
<canvas class="bp__canvas" id="bpCanvas" width="480" height="300"></canvas>
<p class="bp__caption">Velocity is computed from how far the ball moved over the last few frames while held, so releasing it throws it at that exact speed, and it reflects off any wall it hits.</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-orange);
.bp {
position: relative;
width: min(92vw, calc(160vh - 12.8vw));
aspect-ratio: 480 / 300;
margin: 0 auto;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
background: $stage-orange;
touch-action: none;
}
.bp__canvas {
display: block;
width: 100%;
height: 100%;
}
.bp__caption {
position: absolute;
left: $sp-3;
bottom: $sp-3;
max-width: 80%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.bp__caption { font-size: 10px; padding: $sp-1 $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.bp { cursor: default; }
}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);
}
.bp {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: #ff4d1f;
touch-action: none;
}
.bp__canvas {
display: block;
width: 100%;
height: 100%;
}
.bp__caption {
position: absolute;
left: 12px;
bottom: 12px;
max-width: 80%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.bp__caption {
font-size: 10px;
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.bp {
cursor: default;
}
}(function () {
// 기법: 드래그 중 포인터 위치·시각을 최근 몇 개만 버퍼에 담아 두고, 놓는 순간 버퍼의
// 처음-마지막 샘플로 속도(이동거리/경과시간)를 계산해 그 속도로 공을 던진다(스냅샷
// 한 프레임 차분이 아니라 최근 구간 평균이라 손떨림에 덜 흔들린다). 던져진 공은
// 공기 저항으로 서서히 느려지며 벽에 부딪힐 때마다 WALL_BOUNCE 만큼 반사·감쇠한다.
var root = document.getElementById('bpRoot');
var canvas = document.getElementById('bpCanvas');
var ctx = canvas.getContext('2d');
var wrap = canvas.parentElement;
var CALC_W = 320, CALC_H = 200;
var WALL_BOUNCE = 0.72;
var AIR_DRAG = 0.55;
var SAMPLE_WINDOW_MS = 140;
var KEY_IMPULSE = 130;
var THROW_IMPULSE = 260;
function seedRand(seed) {
var s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
var rand = seedRand(427071);
var ball = { x: CALC_W * 0.5, y: CALC_H * 0.5, vx: 90, vy: -60, r: 18, color: '#ffcf7a', squash: 0, heading: -0.9 };
var dragging = false;
var samples = [];
function recordSample(x, y, t) {
samples.push({ x: x, y: y, t: t });
while (samples.length && t - samples[0].t > SAMPLE_WINDOW_MS) samples.shift();
}
function releaseVelocity(nowT) {
if (samples.length < 2) return { vx: 0, vy: 0 };
var a = samples[0], b = samples[samples.length - 1];
var dt = Math.max(16, b.t - a.t) / 1000;
return { vx: (b.x - a.x) / dt, vy: (b.y - a.y) / dt };
}
function throwBall(vx, vy) {
ball.vx = vx; ball.vy = vy;
if (Math.hypot(vx, vy) > 1) ball.heading = Math.atan2(vy, vx);
}
function stepSim(dtMs) {
var dtS = dtMs / 1000;
if (dragging) return;
ball.vx -= ball.vx * AIR_DRAG * dtS;
ball.vy -= ball.vy * AIR_DRAG * dtS;
ball.x += ball.vx * dtS;
ball.y += ball.vy * dtS;
var hit = false;
if (ball.x - ball.r < 0) { ball.x = ball.r; ball.vx = -ball.vx * WALL_BOUNCE; hit = true; }
if (ball.x + ball.r > CALC_W) { ball.x = CALC_W - ball.r; ball.vx = -ball.vx * WALL_BOUNCE; hit = true; }
if (ball.y - ball.r < 0) { ball.y = ball.r; ball.vy = -ball.vy * WALL_BOUNCE; hit = true; }
if (ball.y + ball.r > CALC_H) { ball.y = CALC_H - ball.r; ball.vy = -ball.vy * WALL_BOUNCE; hit = true; }
if (hit) ball.squash = Math.min(1, Math.hypot(ball.vx, ball.vy) / 260 + 0.2);
ball.squash = Math.max(0, ball.squash - dtS * 3.4);
}
function draw() {
var sx = cw / CALC_W, sy = ch / CALC_H;
ctx.fillStyle = 'rgba(255,110,46,0.24)';
ctx.fillRect(0, 0, cw, ch);
ctx.strokeStyle = 'rgba(255,255,255,0.3)';
ctx.lineWidth = 2;
ctx.strokeRect(2, 2, cw - 4, ch - 4);
var px = ball.x * sx, py = ball.y * sy, pr = ball.r * ((sx + sy) / 2);
var speed = Math.hypot(ball.vx, ball.vy);
var stretch = dragging ? 1.08 : 1 + Math.min(0.4, speed / 600);
ctx.save();
ctx.translate(px, py);
ctx.rotate(ball.heading);
ctx.scale(stretch, (1 - ball.squash * 0.3) / stretch);
ctx.beginPath();
ctx.arc(0, 0, pr, 0, Math.PI * 2);
ctx.fillStyle = ball.color;
ctx.fill();
ctx.restore();
if (dragging) {
ctx.beginPath();
ctx.arc(px, py, pr + 6, 0, Math.PI * 2);
ctx.strokeStyle = 'rgba(255,255,255,0.6)';
ctx.lineWidth = 2;
ctx.stroke();
}
}
var cw = 480, ch = 300;
function fit() {
var r = wrap.getBoundingClientRect();
cw = Math.max(1, Math.round(r.width));
ch = Math.max(1, Math.round(r.height));
canvas.width = cw; canvas.height = ch;
draw();
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var elapsed = 0;
if (reduce) root.classList.remove('is-demo');
function toCalc(clientX, clientY) {
var rect = canvas.getBoundingClientRect();
var nx = (clientX - rect.left) / rect.width;
var ny = (clientY - rect.top) / rect.height;
return { x: nx * CALC_W, y: ny * CALC_H };
}
function startDrag(cx, cy) {
root.classList.remove('is-demo');
var p = toCalc(cx, cy);
if (Math.hypot(p.x - ball.x, p.y - ball.y) > ball.r * 2.2) return;
dragging = true;
samples = [];
recordSample(p.x, p.y, performance.now());
}
function moveDrag(cx, cy) {
if (!dragging) return;
var p = toCalc(cx, cy);
ball.x = Math.max(ball.r, Math.min(CALC_W - ball.r, p.x));
ball.y = Math.max(ball.r, Math.min(CALC_H - ball.r, p.y));
recordSample(ball.x, ball.y, performance.now());
if (reduce) draw();
}
function endDrag() {
if (!dragging) return;
dragging = false;
var v = releaseVelocity(performance.now());
var mag = Math.hypot(v.vx, v.vy);
var maxSpeed = 420;
if (mag > maxSpeed) { v.vx = v.vx / mag * maxSpeed; v.vy = v.vy / mag * maxSpeed; }
throwBall(v.vx, v.vy);
if (reduce) { stepSim(16); draw(); }
}
canvas.addEventListener('mousedown', function (e) { startDrag(e.clientX, e.clientY); });
window.addEventListener('mousemove', function (e) { moveDrag(e.clientX, e.clientY); });
window.addEventListener('mouseup', endDrag);
canvas.addEventListener('touchstart', function (e) {
if (e.touches && e.touches[0]) startDrag(e.touches[0].clientX, e.touches[0].clientY);
}, { passive: true });
canvas.addEventListener('touchmove', function (e) {
if (e.touches && e.touches[0]) moveDrag(e.touches[0].clientX, e.touches[0].clientY);
}, { passive: true });
canvas.addEventListener('touchend', endDrag);
root.addEventListener('keydown', function (e) {
var handled = true;
if (e.key === 'ArrowLeft') throwBall(ball.vx - KEY_IMPULSE, ball.vy);
else if (e.key === 'ArrowRight') throwBall(ball.vx + KEY_IMPULSE, ball.vy);
else if (e.key === 'ArrowUp') throwBall(ball.vx, ball.vy - KEY_IMPULSE);
else if (e.key === 'ArrowDown') throwBall(ball.vx, ball.vy + KEY_IMPULSE);
else if (e.key === ' ' || e.code === 'Space') {
var ang = ball.heading;
throwBall(Math.cos(ang) * THROW_IMPULSE, Math.sin(ang) * THROW_IMPULSE);
} else handled = false;
if (handled) {
e.preventDefault();
root.classList.remove('is-demo');
if (reduce) { stepSim(16); draw(); }
}
});
// 자동재생: 가상 드래그 경로를 샘플로 기록한 뒤 같은 releaseVelocity/throwBall 을
// 호출한다 — 실제 입력과 동일한 함수 경로다.
var demoState = 'idle';
var demoT0 = 0;
function demoDrive(nowMs) {
var cyc = nowMs % 1600;
if (cyc < 480) {
if (demoState !== 'drag') { demoState = 'drag'; dragging = true; samples = []; demoT0 = nowMs; }
var f = cyc / 480;
var sx0 = CALC_W * 0.28, sy0 = CALC_H * 0.7;
var ex0 = CALC_W * 0.62, ey0 = CALC_H * 0.32;
ball.x = sx0 + (ex0 - sx0) * f;
ball.y = sy0 + (ey0 - sy0) * f;
recordSample(ball.x, ball.y, nowMs);
} else if (demoState !== 'thrown') {
demoState = 'thrown';
dragging = false;
var v = releaseVelocity(nowMs);
var mag = Math.hypot(v.vx, v.vy) || 1;
var target = 300;
throwBall(v.vx / mag * target, v.vy / mag * target);
}
}
var lastTs = null;
var acc = 0;
var FIXED_DT = 16;
function tick(ts) {
if (lastTs === null) lastTs = ts;
var frameDt = Math.min(ts - lastTs, 48);
lastTs = ts;
if (!reduce) {
acc += frameDt;
while (acc >= FIXED_DT) {
elapsed += FIXED_DT;
if (root.classList.contains('is-demo')) demoDrive(elapsed);
stepSim(FIXED_DT);
acc -= FIXED_DT;
}
draw();
}
requestAnimationFrame(tick);
}
fit();
window.addEventListener('resize', fit);
draw();
requestAnimationFrame(tick);
})();import { useEffect, useRef } from "react";
import styles from "./BallPhysicsDragThrowMomentum.module.scss";
type Sample = { x: number; y: number; t: number };
export default function BallPhysicsDragThrowMomentum() {
const rootRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const root = rootRef.current;
const canvas = canvasRef.current;
if (!root || !canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const wrap = canvas.parentElement as HTMLElement;
const CALC_W = 320, CALC_H = 200;
const WALL_BOUNCE = 0.72;
const AIR_DRAG = 0.55;
const SAMPLE_WINDOW_MS = 140;
const KEY_IMPULSE = 130;
const THROW_IMPULSE = 260;
const ball = { x: CALC_W * 0.5, y: CALC_H * 0.5, vx: 90, vy: -60, r: 18, color: "#ffcf7a", squash: 0, heading: -0.9 };
let dragging = false;
let samples: Sample[] = [];
function recordSample(x: number, y: number, t: number) {
samples.push({ x, y, t });
while (samples.length && t - samples[0].t > SAMPLE_WINDOW_MS) samples.shift();
}
function releaseVelocity() {
if (samples.length < 2) return { vx: 0, vy: 0 };
const a = samples[0], b = samples[samples.length - 1];
const dt = Math.max(16, b.t - a.t) / 1000;
return { vx: (b.x - a.x) / dt, vy: (b.y - a.y) / dt };
}
function throwBall(vx: number, vy: number) {
ball.vx = vx; ball.vy = vy;
if (Math.hypot(vx, vy) > 1) ball.heading = Math.atan2(vy, vx);
}
function stepSim(dtMs: number) {
const dtS = dtMs / 1000;
if (dragging) return;
ball.vx -= ball.vx * AIR_DRAG * dtS;
ball.vy -= ball.vy * AIR_DRAG * dtS;
ball.x += ball.vx * dtS;
ball.y += ball.vy * dtS;
let hit = false;
if (ball.x - ball.r < 0) { ball.x = ball.r; ball.vx = -ball.vx * WALL_BOUNCE; hit = true; }
if (ball.x + ball.r > CALC_W) { ball.x = CALC_W - ball.r; ball.vx = -ball.vx * WALL_BOUNCE; hit = true; }
if (ball.y - ball.r < 0) { ball.y = ball.r; ball.vy = -ball.vy * WALL_BOUNCE; hit = true; }
if (ball.y + ball.r > CALC_H) { ball.y = CALC_H - ball.r; ball.vy = -ball.vy * WALL_BOUNCE; hit = true; }
if (hit) ball.squash = Math.min(1, Math.hypot(ball.vx, ball.vy) / 260 + 0.2);
ball.squash = Math.max(0, ball.squash - dtS * 3.4);
}
let cw = 480, ch = 300;
function draw() {
const sx = cw / CALC_W, sy = ch / CALC_H;
ctx!.fillStyle = "rgba(255,110,46,0.24)";
ctx!.fillRect(0, 0, cw, ch);
ctx!.strokeStyle = "rgba(255,255,255,0.3)";
ctx!.lineWidth = 2;
ctx!.strokeRect(2, 2, cw - 4, ch - 4);
const px = ball.x * sx, py = ball.y * sy, pr = ball.r * ((sx + sy) / 2);
const speed = Math.hypot(ball.vx, ball.vy);
const stretch = dragging ? 1.08 : 1 + Math.min(0.4, speed / 600);
ctx!.save();
ctx!.translate(px, py);
ctx!.rotate(ball.heading);
ctx!.scale(stretch, (1 - ball.squash * 0.3) / stretch);
ctx!.beginPath();
ctx!.arc(0, 0, pr, 0, Math.PI * 2);
ctx!.fillStyle = ball.color;
ctx!.fill();
ctx!.restore();
if (dragging) {
ctx!.beginPath();
ctx!.arc(px, py, pr + 6, 0, Math.PI * 2);
ctx!.strokeStyle = "rgba(255,255,255,0.6)";
ctx!.lineWidth = 2;
ctx!.stroke();
}
}
function fit() {
const r = wrap.getBoundingClientRect();
cw = Math.max(1, Math.round(r.width));
ch = Math.max(1, Math.round(r.height));
canvas!.width = cw; canvas!.height = ch;
draw();
}
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let elapsed = 0;
if (reduce) root.classList.remove("is-demo");
function toCalc(clientX: number, clientY: number) {
const rect = canvas!.getBoundingClientRect();
const nx = (clientX - rect.left) / rect.width;
const ny = (clientY - rect.top) / rect.height;
return { x: nx * CALC_W, y: ny * CALC_H };
}
function startDrag(cx: number, cy: number) {
root!.classList.remove("is-demo");
const p = toCalc(cx, cy);
if (Math.hypot(p.x - ball.x, p.y - ball.y) > ball.r * 2.2) return;
dragging = true;
samples = [];
recordSample(p.x, p.y, performance.now());
}
function moveDrag(cx: number, cy: number) {
if (!dragging) return;
const p = toCalc(cx, cy);
ball.x = Math.max(ball.r, Math.min(CALC_W - ball.r, p.x));
ball.y = Math.max(ball.r, Math.min(CALC_H - ball.r, p.y));
recordSample(ball.x, ball.y, performance.now());
if (reduce) draw();
}
function endDrag() {
if (!dragging) return;
dragging = false;
const v = releaseVelocity();
const mag = Math.hypot(v.vx, v.vy);
const maxSpeed = 420;
if (mag > maxSpeed) { v.vx = (v.vx / mag) * maxSpeed; v.vy = (v.vy / mag) * maxSpeed; }
throwBall(v.vx, v.vy);
if (reduce) { stepSim(16); draw(); }
}
function onMouseDown(e: MouseEvent) { startDrag(e.clientX, e.clientY); }
function onMouseMove(e: MouseEvent) { moveDrag(e.clientX, e.clientY); }
function onMouseUp() { endDrag(); }
function onTouchStart(e: TouchEvent) { if (e.touches[0]) startDrag(e.touches[0].clientX, e.touches[0].clientY); }
function onTouchMove(e: TouchEvent) { if (e.touches[0]) moveDrag(e.touches[0].clientX, e.touches[0].clientY); }
function onTouchEnd() { endDrag(); }
canvas.addEventListener("mousedown", onMouseDown);
window.addEventListener("mousemove", onMouseMove);
window.addEventListener("mouseup", onMouseUp);
canvas.addEventListener("touchstart", onTouchStart, { passive: true });
canvas.addEventListener("touchmove", onTouchMove, { passive: true });
canvas.addEventListener("touchend", onTouchEnd);
function onKeyDown(e: KeyboardEvent) {
let handled = true;
if (e.key === "ArrowLeft") throwBall(ball.vx - KEY_IMPULSE, ball.vy);
else if (e.key === "ArrowRight") throwBall(ball.vx + KEY_IMPULSE, ball.vy);
else if (e.key === "ArrowUp") throwBall(ball.vx, ball.vy - KEY_IMPULSE);
else if (e.key === "ArrowDown") throwBall(ball.vx, ball.vy + KEY_IMPULSE);
else if (e.key === " " || e.code === "Space") {
const ang = ball.heading;
throwBall(Math.cos(ang) * THROW_IMPULSE, Math.sin(ang) * THROW_IMPULSE);
} else handled = false;
if (handled) {
e.preventDefault();
root!.classList.remove("is-demo");
if (reduce) { stepSim(16); draw(); }
}
}
root.addEventListener("keydown", onKeyDown);
let demoState = "idle";
function demoDrive(nowMs: number) {
const cyc = nowMs % 1600;
if (cyc < 480) {
if (demoState !== "drag") { demoState = "drag"; dragging = true; samples = []; }
const f = cyc / 480;
const sx0 = CALC_W * 0.28, sy0 = CALC_H * 0.7;
const ex0 = CALC_W * 0.62, ey0 = CALC_H * 0.32;
ball.x = sx0 + (ex0 - sx0) * f;
ball.y = sy0 + (ey0 - sy0) * f;
recordSample(ball.x, ball.y, nowMs);
} else if (demoState !== "thrown") {
demoState = "thrown";
dragging = false;
const v = releaseVelocity();
const mag = Math.hypot(v.vx, v.vy) || 1;
const target = 300;
throwBall((v.vx / mag) * target, (v.vy / mag) * target);
}
}
let lastTs: number | null = null;
let acc = 0;
const FIXED_DT = 16;
let raf = 0;
function tick(ts: number) {
if (lastTs === null) lastTs = ts;
const frameDt = Math.min(ts - lastTs, 48);
lastTs = ts;
if (!reduce) {
acc += frameDt;
while (acc >= FIXED_DT) {
elapsed += FIXED_DT;
if (root!.classList.contains("is-demo")) demoDrive(elapsed);
stepSim(FIXED_DT);
acc -= FIXED_DT;
}
draw();
}
raf = requestAnimationFrame(tick);
}
fit();
window.addEventListener("resize", fit);
draw();
raf = requestAnimationFrame(tick);
return () => {
cancelAnimationFrame(raf);
window.removeEventListener("resize", fit);
canvas!.removeEventListener("mousedown", onMouseDown);
window.removeEventListener("mousemove", onMouseMove);
window.removeEventListener("mouseup", onMouseUp);
canvas!.removeEventListener("touchstart", onTouchStart);
canvas!.removeEventListener("touchmove", onTouchMove);
canvas!.removeEventListener("touchend", onTouchEnd);
root!.removeEventListener("keydown", onKeyDown);
};
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.bp} is-demo`} ref={rootRef} tabIndex={0}>
<canvas className={styles.bp__canvas} ref={canvasRef} width={480} height={300} />
<p className={styles.bp__caption}>공을 쥐고 움직인 최근 몇 프레임의 이동량으로 속도를 계산해, 놓는 순간 그 속도 그대로 던져지고 벽에 부딪히면 반사된다</p>
</div>
</main>
);
}07 Ball Thrown at the Exact Speed of Its Drag — Measured area 16.193% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · 9 Bouncing Ball Animation Patterns With Physics
The post this demo lives in
9 Bouncing Ball Animation Patterns With PhysicsThis bouncing ball animation set has nine builds below. Ball 01 trades velocity through a 0.82 restitution coefficient, See all demos in this post


