Spring Physics Drag
Draggable cards powered by a real damped-spring integrator running in requestAnimationFrame. Fling a card and watch it coast on inertia, rubber-band past its bounds with elastic resistance, then settle back onto the grid with critically tuned overshoot. Live stiffness and damping sliders let you feel the difference between a stiff snap and a loose wobble, and everything degrades to an instant, motion-safe placement when the visitor prefers reduced motion.
MCP
Code
:root {
--bg: #0c0d10;
--surface: #15161b;
--surface-2: #1d1f27;
--ink: #e9eaf0;
--muted: #9a9cab;
--accent: #8b5cf6;
--accent-2: #22d3ee;
--ok: #34d399;
--warn: #fbbf24;
--danger: #f87171;
--line: rgba(255, 255, 255, 0.10);
--line-2: rgba(255, 255, 255, 0.18);
--r-sm: 8px;
--r-md: 12px;
--r-lg: 16px;
}
* { box-sizing: border-box; }
html { -webkit-text-size-adjust: 100%; }
body {
margin: 0;
background:
radial-gradient(1100px 620px at 78% -8%, rgba(139, 92, 246, 0.16), transparent 60%),
radial-gradient(900px 520px at 8% 108%, rgba(34, 211, 238, 0.10), transparent 62%),
var(--bg);
color: var(--ink);
font-family: "Inter", system-ui, -apple-system, "Segoe UI", sans-serif;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
min-height: 100vh;
}
code, .mono { font-family: "JetBrains Mono", ui-monospace, "SFMono-Regular", monospace; }
.app {
max-width: 1060px;
margin: 0 auto;
padding: 28px 20px 56px;
}
/* ---- Header ---- */
.head {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
margin-bottom: 22px;
}
.head-text { max-width: 640px; }
h1 {
margin: 0 0 8px;
font-size: clamp(1.5rem, 1.1rem + 1.7vw, 2.1rem);
font-weight: 700;
letter-spacing: -0.02em;
}
.sub { margin: 0; color: var(--muted); font-size: 0.95rem; }
.sub code {
background: var(--surface-2);
border: 1px solid var(--line);
padding: 1px 6px;
border-radius: 6px;
font-size: 0.82em;
color: var(--accent-2);
}
.rm-badge {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
border: 1px solid var(--line);
border-radius: 999px;
background: var(--surface);
color: var(--warn);
font-size: 0.8rem;
font-weight: 500;
}
.rm-badge .dot {
width: 8px; height: 8px; border-radius: 50%;
background: var(--warn);
box-shadow: 0 0 0 4px rgba(251, 191, 36, 0.16);
}
/* ---- Stage ---- */
.stage-wrap {
display: grid;
grid-template-columns: 1fr 288px;
gap: 18px;
align-items: stretch;
}
.board {
position: relative;
min-height: 440px;
border: 1px solid var(--line);
border-radius: var(--r-lg);
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.02), transparent 40%),
var(--surface);
background-image:
linear-gradient(rgba(255, 255, 255, 0.028) 1px, transparent 1px),
linear-gradient(90deg, rgba(255, 255, 255, 0.028) 1px, transparent 1px);
background-size: 100% 100%, 34px 34px, 34px 34px;
overflow: hidden;
touch-action: none;
}
.card {
position: absolute;
width: 158px;
height: 118px;
border-radius: var(--r-md);
border: 1px solid var(--line-2);
background: linear-gradient(155deg, var(--c1), var(--c2));
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.34), inset 0 1px 0 rgba(255, 255, 255, 0.14);
padding: 14px;
cursor: grab;
will-change: transform;
transform: translate3d(0, 0, 0);
user-select: none;
-webkit-user-select: none;
display: flex;
flex-direction: column;
justify-content: space-between;
color: #0b0c10;
}
.card:focus-visible {
outline: 2px solid var(--accent-2);
outline-offset: 3px;
}
.card.dragging {
cursor: grabbing;
z-index: 40;
box-shadow: 0 22px 46px rgba(0, 0, 0, 0.5), inset 0 1px 0 rgba(255, 255, 255, 0.2);
}
.card.settling { z-index: 30; }
.card .c-idx {
font-family: "JetBrains Mono", monospace;
font-weight: 600;
font-size: 0.72rem;
opacity: 0.62;
letter-spacing: 0.04em;
}
.card .c-title {
font-weight: 700;
font-size: 0.98rem;
letter-spacing: -0.01em;
}
.card .c-meta {
font-family: "JetBrains Mono", monospace;
font-size: 0.68rem;
opacity: 0.7;
}
.card .grab {
position: absolute;
top: 10px; right: 12px;
display: grid;
grid-template-columns: repeat(2, 3px);
gap: 3px;
opacity: 0.5;
}
.card .grab i { width: 3px; height: 3px; border-radius: 50%; background: currentColor; }
/* ---- Panel ---- */
.panel {
border: 1px solid var(--line);
border-radius: var(--r-lg);
background: var(--surface);
padding: 16px;
display: flex;
flex-direction: column;
gap: 16px;
}
.panel-group { display: flex; flex-direction: column; gap: 15px; }
.ctrl { display: grid; grid-template-columns: 1fr auto; align-items: center; gap: 4px 10px; }
.ctrl label {
grid-column: 1 / 2;
font-size: 0.82rem;
font-weight: 600;
display: flex;
align-items: baseline;
gap: 7px;
}
.ctrl .hint { font-weight: 400; color: var(--muted); font-size: 0.72rem; }
.ctrl output {
grid-column: 2 / 3;
grid-row: 1;
font-family: "JetBrains Mono", monospace;
font-size: 0.82rem;
color: var(--accent-2);
min-width: 34px;
text-align: right;
}
.ctrl input[type="range"] { grid-column: 1 / 3; }
input[type="range"] {
-webkit-appearance: none;
appearance: none;
width: 100%;
height: 6px;
border-radius: 999px;
background: var(--surface-2);
border: 1px solid var(--line);
outline: none;
margin: 4px 0 2px;
}
input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 16px; height: 16px;
border-radius: 50%;
background: var(--accent);
border: 2px solid #fff;
cursor: pointer;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.4);
}
input[type="range"]::-moz-range-thumb {
width: 16px; height: 16px;
border-radius: 50%;
background: var(--accent);
border: 2px solid #fff;
cursor: pointer;
}
input[type="range"]:focus-visible {
border-color: var(--accent-2);
box-shadow: 0 0 0 3px rgba(34, 211, 238, 0.22);
}
.presets {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
.presets button {
font: inherit;
font-size: 0.8rem;
font-weight: 600;
color: var(--ink);
background: var(--surface-2);
border: 1px solid var(--line);
border-radius: var(--r-sm);
padding: 8px 10px;
cursor: pointer;
transition: border-color 0.15s, background 0.15s, transform 0.05s;
}
.presets button:hover { border-color: var(--line-2); background: #23262f; }
.presets button:active { transform: translateY(1px); }
.presets button.active {
border-color: var(--accent);
background: rgba(139, 92, 246, 0.16);
color: #fff;
}
.presets button:focus-visible { outline: 2px solid var(--accent-2); outline-offset: 2px; }
.readout {
margin: 0;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px 12px;
padding: 12px;
border: 1px solid var(--line);
border-radius: var(--r-md);
background: var(--surface-2);
}
.readout div { display: flex; flex-direction: column; gap: 2px; }
.readout dt { font-size: 0.68rem; color: var(--muted); text-transform: uppercase; letter-spacing: 0.05em; }
.readout dd {
margin: 0;
font-family: "JetBrains Mono", monospace;
font-size: 0.86rem;
font-weight: 500;
}
.chip {
display: inline-block;
padding: 1px 8px;
border-radius: 999px;
font-size: 0.72rem;
font-weight: 600;
}
.chip-rest { background: rgba(52, 211, 153, 0.16); color: var(--ok); }
.chip-drag { background: rgba(34, 211, 238, 0.16); color: var(--accent-2); }
.chip-spring { background: rgba(139, 92, 246, 0.2); color: #c4b5fd; }
.energy .energy-label {
display: flex;
justify-content: space-between;
font-size: 0.76rem;
color: var(--muted);
margin-bottom: 6px;
}
.energy #energyPct { font-family: "JetBrains Mono", monospace; color: var(--ink); }
.energy-track {
height: 8px;
border-radius: 999px;
background: var(--surface-2);
border: 1px solid var(--line);
overflow: hidden;
}
.energy-fill {
height: 100%;
width: 0%;
border-radius: 999px;
background: linear-gradient(90deg, var(--accent), var(--accent-2));
transition: width 0.08s linear;
}
.reset {
font: inherit;
font-weight: 600;
font-size: 0.85rem;
color: var(--ink);
background: var(--surface-2);
border: 1px solid var(--line);
border-radius: var(--r-sm);
padding: 10px;
cursor: pointer;
transition: border-color 0.15s, background 0.15s;
}
.reset:hover { border-color: var(--danger); color: #fff; }
.reset:focus-visible { outline: 2px solid var(--accent-2); outline-offset: 2px; }
.note {
margin-top: 18px;
padding: 14px 16px;
border: 1px solid var(--line);
border-radius: var(--r-md);
background: var(--surface);
color: var(--muted);
font-size: 0.86rem;
}
.note strong { color: var(--ink); }
.note em { color: var(--accent-2); font-style: normal; }
.note code {
font-family: "JetBrains Mono", monospace;
background: var(--surface-2);
border: 1px solid var(--line);
padding: 1px 6px;
border-radius: 6px;
font-size: 0.82em;
color: #c4b5fd;
white-space: nowrap;
}
/* ---- Responsive ---- */
@media (max-width: 820px) {
.stage-wrap { grid-template-columns: 1fr; }
.board { min-height: 380px; }
}
@media (max-width: 520px) {
.app { padding: 20px 14px 44px; }
.card { width: 132px; height: 104px; padding: 12px; }
.readout { grid-template-columns: 1fr 1fr; }
.presets { grid-template-columns: 1fr 1fr; }
}
@media (prefers-reduced-motion: reduce) {
.energy-fill { transition: none; }
.presets button, .reset { transition: none; }
}(() => {
"use strict";
const board = document.getElementById("board");
const rmBadge = document.getElementById("rmBadge");
const stiffnessEl = document.getElementById("stiffness");
const dampingEl = document.getElementById("damping");
const resistanceEl = document.getElementById("resistance");
const stiffnessOut = document.getElementById("stiffnessOut");
const dampingOut = document.getElementById("dampingOut");
const resistanceOut = document.getElementById("resistanceOut");
const roCard = document.getElementById("roCard");
const roVel = document.getElementById("roVel");
const roOff = document.getElementById("roOff");
const roState = document.getElementById("roState");
const energyFill = document.getElementById("energyFill");
const energyPct = document.getElementById("energyPct");
const resetBtn = document.getElementById("reset");
const reduceMotionMQ = window.matchMedia("(prefers-reduced-motion: reduce)");
let reduceMotion = reduceMotionMQ.matches;
rmBadge.hidden = !reduceMotion;
reduceMotionMQ.addEventListener("change", (e) => {
reduceMotion = e.matches;
rmBadge.hidden = !reduceMotion;
});
// ---- Spring parameters (live) ----
const params = {
stiffness: Number(stiffnessEl.value),
damping: Number(dampingEl.value),
resistance: Number(resistanceEl.value),
};
const PRESETS = {
snappy: { stiffness: 480, damping: 34, resistance: 45 },
smooth: { stiffness: 220, damping: 26, resistance: 55 },
wobbly: { stiffness: 300, damping: 12, resistance: 65 },
molasses: { stiffness: 90, damping: 26, resistance: 80 },
};
const CARD_DATA = [
{ title: "Payments", meta: "svc/pay", c1: "#a78bfa", c2: "#6d28d9" },
{ title: "Auth", meta: "svc/auth", c1: "#67e8f9", c2: "#0e7490" },
{ title: "Search", meta: "svc/search", c1: "#6ee7b7", c2: "#047857" },
{ title: "Media", meta: "svc/media", c1: "#fcd34d", c2: "#b45309" },
{ title: "Queue", meta: "svc/queue", c1: "#fda4af", c2: "#9f1239" },
{ title: "Metrics", meta: "svc/metrics", c1: "#c4b5fd", c2: "#5b21b6" },
];
const cards = [];
let active = null; // currently dragged card
// ---- Layout the grid homes ----
function computeHomes() {
const rect = board.getBoundingClientRect();
const sample = document.querySelector(".card");
const cw = sample ? sample.offsetWidth : 158;
const ch = sample ? sample.offsetHeight : 118;
const gap = 18;
const pad = 20;
const cols = Math.max(1, Math.floor((rect.width - pad * 2 + gap) / (cw + gap)));
return { cw, ch, gap, pad, cols, width: rect.width, height: rect.height };
}
function assignHomes() {
const L = computeHomes();
cards.forEach((card, i) => {
const col = i % L.cols;
const row = Math.floor(i / L.cols);
card.homeX = L.pad + col * (L.cw + L.gap);
card.homeY = L.pad + row * (L.ch + L.gap);
card.cw = L.cw;
card.ch = L.ch;
card.el.style.left = card.homeX + "px";
card.el.style.top = card.homeY + "px";
});
boardBounds = L;
}
let boardBounds = null;
// ---- Build cards ----
function build() {
board.innerHTML = "";
cards.length = 0;
CARD_DATA.forEach((d, i) => {
const el = document.createElement("div");
el.className = "card";
el.tabIndex = 0;
el.style.setProperty("--c1", d.c1);
el.style.setProperty("--c2", d.c2);
el.setAttribute("role", "button");
el.setAttribute("aria-label", `${d.title} card. Drag to fling, or use arrow keys to nudge.`);
el.innerHTML =
`<span class="grab"><i></i><i></i><i></i><i></i><i></i><i></i></span>` +
`<span class="c-idx">#${String(i + 1).padStart(2, "0")}</span>` +
`<span class="c-title">${d.title}</span>` +
`<span class="c-meta">${d.meta}</span>`;
board.appendChild(el);
const card = {
el, index: i, title: d.title,
x: 0, y: 0, // offset from home
vx: 0, vy: 0, // velocity px/s
homeX: 0, homeY: 0,
cw: 158, ch: 118,
springing: false,
};
cards.push(card);
attachDrag(card);
attachKeys(card);
});
assignHomes();
render();
}
function render() {
for (const c of cards) {
c.el.style.transform = `translate3d(${c.x.toFixed(2)}px, ${c.y.toFixed(2)}px, 0)`;
}
}
// ---- Over-drag rubber-band resistance ----
// Once the card's edge passes the board bound, extra travel is compressed.
function rubberBand(offset, home, size, min, max) {
const strength = params.resistance / 100; // 0..1
if (strength <= 0) return offset;
const pos = home + offset;
let over = 0;
if (pos < min) over = pos - min;
else if (pos + size > max) over = pos + size - max;
if (over === 0) return offset;
// logarithmic compression of the overshoot portion
const sign = Math.sign(over);
const abs = Math.abs(over);
const damped = sign * (1 - 1 / (abs * 0.0055 * (0.15 + strength) + 1)) / (0.0055 * (0.15 + strength));
return offset - over + damped;
}
// ---- Pointer drag ----
function attachDrag(card) {
const el = card.el;
el.addEventListener("pointerdown", (e) => {
if (e.button != null && e.button !== 0) return;
el.setPointerCapture(e.pointerId);
card.springing = false;
card.el.classList.add("dragging");
card.el.classList.remove("settling");
active = card;
const startX = e.clientX;
const startY = e.clientY;
const baseX = card.x;
const baseY = card.y;
// velocity sampling
let lastX = e.clientX, lastY = e.clientY, lastT = performance.now();
card.vx = 0; card.vy = 0;
const onMove = (ev) => {
const now = performance.now();
const dt = Math.max(1, now - lastT) / 1000;
card.vx = (ev.clientX - lastX) / dt;
card.vy = (ev.clientY - lastY) / dt;
lastX = ev.clientX; lastY = ev.clientY; lastT = now;
let nx = baseX + (ev.clientX - startX);
let ny = baseY + (ev.clientY - startY);
// apply rubber-band at board edges
nx = rubberBand(nx, card.homeX, card.cw, 0, boardBounds.width);
ny = rubberBand(ny, card.homeY, card.ch, 0, boardBounds.height);
card.x = nx; card.y = ny;
card.el.style.transform = `translate3d(${nx.toFixed(2)}px, ${ny.toFixed(2)}px, 0)`;
updateReadout(card, "drag");
};
const onUp = (ev) => {
el.removeEventListener("pointermove", onMove);
el.removeEventListener("pointerup", onUp);
el.removeEventListener("pointercancel", onUp);
try { el.releasePointerCapture(ev.pointerId); } catch (_) {}
card.el.classList.remove("dragging");
// hand velocity to the spring
release(card);
};
el.addEventListener("pointermove", onMove);
el.addEventListener("pointerup", onUp);
el.addEventListener("pointercancel", onUp);
e.preventDefault();
});
}
// ---- Keyboard nudge ----
function attachKeys(card) {
card.el.addEventListener("keydown", (e) => {
const step = e.shiftKey ? 40 : 16;
let handled = true;
switch (e.key) {
case "ArrowLeft": card.x -= step; card.vx = -320; break;
case "ArrowRight": card.x += step; card.vx = 320; break;
case "ArrowUp": card.y -= step; card.vy = -320; break;
case "ArrowDown": card.y += step; card.vy = 320; break;
case "Enter": case " ": card.x += 0; card.vx = 0; card.vy = 0; break;
default: handled = false;
}
if (handled) {
e.preventDefault();
active = card;
release(card);
}
});
}
// ---- Release: start the spring ----
function release(card) {
if (reduceMotion) {
card.x = 0; card.y = 0; card.vx = 0; card.vy = 0;
card.springing = false;
card.el.classList.remove("settling");
card.el.style.transform = "translate3d(0,0,0)";
updateReadout(card, "rest");
return;
}
card.springing = true;
card.el.classList.add("settling");
updateReadout(card, "spring");
ensureLoop();
}
// ---- The integrator loop (semi-implicit Euler damped spring) ----
let rafId = null;
let lastFrame = 0;
const REST_POS = 0.15; // px
const REST_VEL = 12; // px/s
function ensureLoop() {
if (rafId != null) return;
lastFrame = performance.now();
rafId = requestAnimationFrame(step);
}
function step(now) {
// clamp dt so a tab-switch doesn't explode the sim
let dt = (now - lastFrame) / 1000;
lastFrame = now;
if (dt > 0.032) dt = 0.032;
if (dt <= 0) dt = 1 / 60;
const k = params.stiffness;
const c = params.damping;
let anySpringing = false;
let readoutCard = active && active.springing ? active : null;
for (const card of cards) {
if (!card.springing) continue;
anySpringing = true;
// sub-step for stability with stiff springs
const sub = 2;
const h = dt / sub;
for (let s = 0; s < sub; s++) {
// acceleration toward home (target offset = 0)
const ax = -k * card.x - c * card.vx;
const ay = -k * card.y - c * card.vy;
card.vx += ax * h;
card.vy += ay * h;
card.x += card.vx * h;
card.y += card.vy * h;
}
// rest test
const speed = Math.hypot(card.vx, card.vy);
const dist = Math.hypot(card.x, card.y);
if (speed < REST_VEL && dist < REST_POS) {
card.x = 0; card.y = 0; card.vx = 0; card.vy = 0;
card.springing = false;
card.el.classList.remove("settling");
if (active === card) updateReadout(card, "rest");
}
}
render();
if (readoutCard && readoutCard.springing) updateReadout(readoutCard, "spring");
if (anySpringing) {
rafId = requestAnimationFrame(step);
} else {
rafId = null;
}
}
// ---- Readout / energy meter ----
function updateReadout(card, state) {
roCard.textContent = card ? card.title : "—";
const speed = card ? Math.hypot(card.vx, card.vy) : 0;
roVel.textContent = `${Math.round(speed)} px/s`;
roOff.textContent = card ? `${Math.round(card.x)}, ${Math.round(card.y)}` : "0, 0";
if (state === "drag") {
roState.innerHTML = '<span class="chip chip-drag">dragging</span>';
} else if (state === "spring") {
roState.innerHTML = '<span class="chip chip-spring">springing</span>';
} else {
roState.innerHTML = '<span class="chip chip-rest">at rest</span>';
}
// spring energy ~ kinetic + potential, normalised for display
let energy = 0;
if (card) {
const k = params.stiffness;
const kinetic = 0.5 * (card.vx * card.vx + card.vy * card.vy);
const potential = 0.5 * k * (card.x * card.x + card.y * card.y);
energy = kinetic + potential;
}
const pct = Math.max(0, Math.min(100, Math.round(Math.sqrt(energy) / 42)));
energyFill.style.width = pct + "%";
energyPct.textContent = pct + "%";
}
// ---- Controls wiring ----
function syncOutputs() {
stiffnessOut.textContent = params.stiffness;
dampingOut.textContent = params.damping;
resistanceOut.textContent = params.resistance;
}
stiffnessEl.addEventListener("input", () => { params.stiffness = Number(stiffnessEl.value); syncOutputs(); markPreset(); });
dampingEl.addEventListener("input", () => { params.damping = Number(dampingEl.value); syncOutputs(); markPreset(); });
resistanceEl.addEventListener("input", () => { params.resistance = Number(resistanceEl.value); syncOutputs(); markPreset(); });
const presetBtns = Array.from(document.querySelectorAll(".presets button"));
presetBtns.forEach((btn) => {
btn.addEventListener("click", () => {
const p = PRESETS[btn.dataset.preset];
if (!p) return;
params.stiffness = p.stiffness;
params.damping = p.damping;
params.resistance = p.resistance;
stiffnessEl.value = p.stiffness;
dampingEl.value = p.damping;
resistanceEl.value = p.resistance;
syncOutputs();
markPreset();
// give a little kick to the first card so the effect is felt
demoFling();
});
});
function markPreset() {
let matched = null;
for (const key in PRESETS) {
const p = PRESETS[key];
if (p.stiffness === params.stiffness && p.damping === params.damping && p.resistance === params.resistance) {
matched = key; break;
}
}
presetBtns.forEach((b) => b.classList.toggle("active", b.dataset.preset === matched));
}
function demoFling() {
if (reduceMotion || cards.length === 0) return;
const c = cards[0];
c.x = 130; c.y = -60;
c.vx = -260; c.vy = 340;
active = c;
release(c);
}
resetBtn.addEventListener("click", () => {
for (const card of cards) {
card.x = 0; card.y = 0; card.vx = 0; card.vy = 0;
card.springing = false;
card.el.classList.remove("settling", "dragging");
card.el.style.transform = "translate3d(0,0,0)";
}
if (rafId != null) { cancelAnimationFrame(rafId); rafId = null; }
active = null;
updateReadout(null, "rest");
});
// reflow homes on resize
let resizeRaf = null;
window.addEventListener("resize", () => {
if (resizeRaf) cancelAnimationFrame(resizeRaf);
resizeRaf = requestAnimationFrame(() => {
assignHomes();
render();
});
});
// ---- Init ----
build();
syncOutputs();
markPreset();
updateReadout(null, "rest");
})();<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Spring Physics Drag</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="style.css" />
</head>
<body>
<main class="app">
<header class="head">
<div class="head-text">
<h1>Spring Physics Drag</h1>
<p class="sub">Grab a card and fling it. A damped-spring integrator in <code>requestAnimationFrame</code> carries the momentum, rubber-bands past the edges, then settles it back onto the grid.</p>
</div>
<div class="rm-badge" id="rmBadge" hidden>
<span class="dot"></span> Reduced motion: cards snap instantly
</div>
</header>
<section class="stage-wrap">
<div class="board" id="board" role="application" aria-label="Draggable spring cards. Focus a card and use arrow keys to nudge, then release to spring back.">
<!-- cards injected by script -->
</div>
<aside class="panel" aria-label="Spring controls">
<div class="panel-group">
<div class="ctrl">
<label for="stiffness">Stiffness <span class="hint">pull strength</span></label>
<input id="stiffness" type="range" min="40" max="600" step="5" value="220" />
<output id="stiffnessOut" for="stiffness">220</output>
</div>
<div class="ctrl">
<label for="damping">Damping <span class="hint">energy loss</span></label>
<input id="damping" type="range" min="4" max="60" step="1" value="22" />
<output id="dampingOut" for="damping">22</output>
</div>
<div class="ctrl">
<label for="resistance">Over-drag resistance <span class="hint">edge rubber-band</span></label>
<input id="resistance" type="range" min="0" max="100" step="5" value="55" />
<output id="resistanceOut" for="resistance">55</output>
</div>
</div>
<div class="presets" role="group" aria-label="Presets">
<button type="button" data-preset="snappy">Snappy</button>
<button type="button" data-preset="smooth">Smooth</button>
<button type="button" data-preset="wobbly">Wobbly</button>
<button type="button" data-preset="molasses">Molasses</button>
</div>
<dl class="readout" aria-live="off">
<div><dt>Active card</dt><dd id="roCard">—</dd></div>
<div><dt>Velocity</dt><dd id="roVel">0 px/s</dd></div>
<div><dt>Offset</dt><dd id="roOff">0, 0</dd></div>
<div><dt>State</dt><dd id="roState"><span class="chip chip-rest">at rest</span></dd></div>
</dl>
<div class="energy">
<div class="energy-label"><span>Spring energy</span><span id="energyPct">0%</span></div>
<div class="energy-track"><div class="energy-fill" id="energyFill"></div></div>
</div>
<button type="button" class="reset" id="reset">Reset board</button>
</aside>
</section>
<footer class="note">
<strong>How it works.</strong> Each card holds an <em>x/y offset</em> and a <em>velocity</em>. On release, a semi-implicit Euler step runs every frame:
<code>a = -k·x − c·v</code>, then <code>v += a·dt</code>, <code>x += v·dt</code> — the same solver behind React Spring and iOS spring animations. Raise stiffness for a faster snap, lower damping for more overshoot.
</footer>
</main>
<script src="script.js"></script>
</body>
</html>Spring Physics Drag is a small board of cards you can grab, throw, and let go. Instead of the usual CSS transition on release, each card is driven by a per-frame numeric integrator: pointer velocity is sampled during the drag, and on release that velocity seeds a critically-damped spring that pulls the card back to its home slot. The result feels physical — the card carries momentum, overshoots slightly, and settles with a natural decay rather than a canned easing curve.
While you drag past the edges of the board, the card meets an over-drag resistance that scales the excess offset logarithmically, giving the familiar iOS-style rubber-band pull. Two sliders expose the spring’s stiffness and damping constants so you can dial anything from a snappy, near-instant snap to a loose, wobbly bounce and watch the settle time and overshoot change in real time. A velocity readout and a live “energy” meter show when the integrator considers the card at rest.
The technique on show is a semi-implicit Euler spring solver — the same math behind React Spring, Framer Motion, and iOS UIView spring animations — implemented in about forty lines of vanilla JavaScript with no dependencies. It is DPR-agnostic, pointer-events based (mouse, touch, and pen all work), fully keyboard nudge-able, and it honours prefers-reduced-motion by skipping the animation loop and placing cards instantly.