Patterns Medium
Spring Physics
Interactive spring physics simulation where you drag an element and it springs back with configurable stiffness and damping. Uses Hooke's law (F = -kx - cv) with requestAnimationFrame.
Open in Lab
MCP
css javascript vue svelte
Targets: TS JS HTML React Vue Svelte
Code
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: system-ui, -apple-system, sans-serif;
background: #0a0a0a;
color: #e4e4e7;
min-height: 100vh;
display: grid;
place-items: center;
padding: 2rem;
overflow: hidden;
}
.demo {
width: min(520px, 100%);
display: flex;
flex-direction: column;
gap: 1.5rem;
align-items: center;
}
.demo-title {
font-size: 1.25rem;
font-weight: 700;
color: #f4f4f5;
text-align: center;
}
.demo-subtitle {
font-size: 0.8rem;
color: #52525b;
margin-top: 0.25rem;
text-align: center;
}
/* ── Spring area ── */
.spring-area {
width: 100%;
height: 340px;
background: rgba(255, 255, 255, 0.02);
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 1rem;
position: relative;
overflow: hidden;
cursor: default;
}
.spring-origin {
position: absolute;
width: 12px;
height: 12px;
border-radius: 50%;
background: rgba(109, 40, 217, 0.3);
border: 2px solid rgba(109, 40, 217, 0.5);
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
pointer-events: none;
}
.spring-line {
position: absolute;
top: 50%;
left: 50%;
transform-origin: 0 0;
height: 2px;
background: linear-gradient(90deg, rgba(109, 40, 217, 0.4), rgba(109, 40, 217, 0.1));
pointer-events: none;
border-radius: 1px;
}
.spring-ball {
position: absolute;
width: 56px;
height: 56px;
border-radius: 50%;
background: radial-gradient(circle at 35% 35%, #a78bfa, #6d28d9);
box-shadow: 0 0 30px rgba(109, 40, 217, 0.4), 0 0 60px rgba(109, 40, 217, 0.15);
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
cursor: grab;
user-select: none;
touch-action: none;
display: grid;
place-items: center;
font-size: 0.65rem;
font-weight: 700;
color: rgba(255, 255, 255, 0.7);
letter-spacing: 0.05em;
}
.spring-ball.dragging {
cursor: grabbing;
box-shadow: 0 0 40px rgba(109, 40, 217, 0.6), 0 0 80px rgba(109, 40, 217, 0.25);
}
/* ── Controls ── */
.controls {
width: 100%;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.control-row {
display: flex;
align-items: center;
gap: 0.75rem;
}
.control-label {
font-size: 0.75rem;
font-weight: 600;
color: #71717a;
min-width: 80px;
text-transform: uppercase;
letter-spacing: 0.06em;
}
.control-slider {
flex: 1;
-webkit-appearance: none;
appearance: none;
height: 4px;
background: rgba(255, 255, 255, 0.08);
border-radius: 2px;
outline: none;
}
.control-slider::-webkit-slider-thumb {
-webkit-appearance: none;
width: 16px;
height: 16px;
border-radius: 50%;
background: #6d28d9;
border: 2px solid #a78bfa;
cursor: pointer;
}
.control-slider::-moz-range-thumb {
width: 16px;
height: 16px;
border-radius: 50%;
background: #6d28d9;
border: 2px solid #a78bfa;
cursor: pointer;
}
.control-value {
font-size: 0.75rem;
font-weight: 600;
color: #a78bfa;
min-width: 40px;
text-align: right;
font-variant-numeric: tabular-nums;
}
/* ── Velocity indicator ── */
.velocity-bar {
width: 100%;
height: 4px;
background: rgba(255, 255, 255, 0.04);
border-radius: 2px;
overflow: hidden;
}
.velocity-fill {
height: 100%;
background: linear-gradient(90deg, #6d28d9, #a78bfa);
border-radius: 2px;
width: 0%;
transition: width 0.05s;
}(function () {
"use strict";
const area = document.getElementById("spring-area");
const ball = document.getElementById("spring-ball");
const line = document.getElementById("spring-line");
const velocityFill = document.getElementById("velocity-fill");
const stiffnessSlider = document.getElementById("stiffness");
const dampingSlider = document.getElementById("damping");
const massSlider = document.getElementById("mass");
const stiffnessVal = document.getElementById("stiffness-val");
const dampingVal = document.getElementById("damping-val");
const massVal = document.getElementById("mass-val");
// Spring state
let posX = 0,
posY = 0;
let velX = 0,
velY = 0;
let isDragging = false;
let dragOffsetX = 0,
dragOffsetY = 0;
function getParams() {
return {
stiffness: Number(stiffnessSlider.value),
damping: Number(dampingSlider.value),
mass: Number(massSlider.value),
};
}
// Update value displays
stiffnessSlider.addEventListener("input", () => {
stiffnessVal.textContent = stiffnessSlider.value;
});
dampingSlider.addEventListener("input", () => {
dampingVal.textContent = dampingSlider.value;
});
massSlider.addEventListener("input", () => {
massVal.textContent = massSlider.value;
});
// ── Drag handling ──
ball.addEventListener("pointerdown", (e) => {
isDragging = true;
ball.classList.add("dragging");
ball.setPointerCapture(e.pointerId);
const rect = area.getBoundingClientRect();
const centerX = rect.width / 2;
const centerY = rect.height / 2;
dragOffsetX = e.clientX - rect.left - centerX - posX;
dragOffsetY = e.clientY - rect.top - centerY - posY;
velX = 0;
velY = 0;
});
window.addEventListener("pointermove", (e) => {
if (!isDragging) return;
const rect = area.getBoundingClientRect();
const centerX = rect.width / 2;
const centerY = rect.height / 2;
posX = e.clientX - rect.left - centerX - dragOffsetX;
posY = e.clientY - rect.top - centerY - dragOffsetY;
});
window.addEventListener("pointerup", () => {
if (!isDragging) return;
isDragging = false;
ball.classList.remove("dragging");
});
// ── Spring simulation loop ──
let lastTime = performance.now();
function simulate(now) {
const dt = Math.min((now - lastTime) / 1000, 0.032); // Cap at ~30fps delta
lastTime = now;
if (!isDragging) {
const { stiffness, damping, mass } = getParams();
// Hooke's law: F = -kx - cv
const forceX = -stiffness * posX - damping * velX;
const forceY = -stiffness * posY - damping * velY;
// Acceleration = F / m
const accX = forceX / mass;
const accY = forceY / mass;
// Velocity integration
velX += accX * dt;
velY += accY * dt;
// Position integration
posX += velX * dt;
posY += velY * dt;
// Settle threshold
if (
Math.abs(posX) < 0.01 &&
Math.abs(posY) < 0.01 &&
Math.abs(velX) < 0.01 &&
Math.abs(velY) < 0.01
) {
posX = 0;
posY = 0;
velX = 0;
velY = 0;
}
}
// Update ball position
ball.style.transform = `translate(calc(-50% + ${posX}px), calc(-50% + ${posY}px))`;
// Update spring line
const dist = Math.sqrt(posX * posX + posY * posY);
const angle = Math.atan2(posY, posX) * (180 / Math.PI);
line.style.width = `${dist}px`;
line.style.transform = `rotate(${angle}deg)`;
// Update velocity indicator
const speed = Math.sqrt(velX * velX + velY * velY);
velocityFill.style.width = `${Math.min(speed / 8, 100)}%`;
requestAnimationFrame(simulate);
}
requestAnimationFrame(simulate);
})();<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Spring Physics</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<div class="demo">
<div>
<h2 class="demo-title">Spring Physics</h2>
<p class="demo-subtitle">Drag the ball and release — it springs back with real physics</p>
</div>
<div class="spring-area" id="spring-area">
<div class="spring-origin"></div>
<div class="spring-line" id="spring-line"></div>
<div class="spring-ball" id="spring-ball">DRAG</div>
</div>
<div class="velocity-bar">
<div class="velocity-fill" id="velocity-fill"></div>
</div>
<div class="controls">
<div class="control-row">
<span class="control-label">Stiffness</span>
<input class="control-slider" id="stiffness" type="range" min="20" max="500" value="180" />
<span class="control-value" id="stiffness-val">180</span>
</div>
<div class="control-row">
<span class="control-label">Damping</span>
<input class="control-slider" id="damping" type="range" min="1" max="40" value="12" />
<span class="control-value" id="damping-val">12</span>
</div>
<div class="control-row">
<span class="control-label">Mass</span>
<input class="control-slider" id="mass" type="range" min="1" max="10" value="1" />
<span class="control-value" id="mass-val">1</span>
</div>
</div>
</div>
<script src="script.js"></script>
</body>
</html>import { useState, useRef, useCallback, useEffect } from "react";
interface SpringConfig {
stiffness: number;
damping: number;
mass: number;
}
export default function SpringPhysics() {
const [config, setConfig] = useState<SpringConfig>({ stiffness: 180, damping: 12, mass: 1 });
const areaRef = useRef<HTMLDivElement>(null);
const ballRef = useRef<HTMLDivElement>(null);
const lineRef = useRef<HTMLDivElement>(null);
const velBarRef = useRef<HTMLDivElement>(null);
const stateRef = useRef({
posX: 0,
posY: 0,
velX: 0,
velY: 0,
isDragging: false,
dragOffsetX: 0,
dragOffsetY: 0,
});
const configRef = useRef(config);
configRef.current = config;
// Animation loop
useEffect(() => {
let raf: number;
let lastTime = performance.now();
function loop(now: number) {
const dt = Math.min((now - lastTime) / 1000, 0.032);
lastTime = now;
const s = stateRef.current;
const c = configRef.current;
if (!s.isDragging) {
const fx = -c.stiffness * s.posX - c.damping * s.velX;
const fy = -c.stiffness * s.posY - c.damping * s.velY;
s.velX += (fx / c.mass) * dt;
s.velY += (fy / c.mass) * dt;
s.posX += s.velX * dt;
s.posY += s.velY * dt;
if (
Math.abs(s.posX) < 0.01 &&
Math.abs(s.posY) < 0.01 &&
Math.abs(s.velX) < 0.01 &&
Math.abs(s.velY) < 0.01
) {
s.posX = 0;
s.posY = 0;
s.velX = 0;
s.velY = 0;
}
}
if (ballRef.current) {
ballRef.current.style.transform = `translate(calc(-50% + ${s.posX}px), calc(-50% + ${s.posY}px))`;
}
if (lineRef.current) {
const dist = Math.sqrt(s.posX * s.posX + s.posY * s.posY);
const angle = Math.atan2(s.posY, s.posX) * (180 / Math.PI);
lineRef.current.style.width = `${dist}px`;
lineRef.current.style.transform = `rotate(${angle}deg)`;
}
if (velBarRef.current) {
const speed = Math.sqrt(s.velX * s.velX + s.velY * s.velY);
velBarRef.current.style.width = `${Math.min(speed / 8, 100)}%`;
}
raf = requestAnimationFrame(loop);
}
raf = requestAnimationFrame(loop);
return () => cancelAnimationFrame(raf);
}, []);
// Pointer events
const onPointerDown = useCallback((e: React.PointerEvent) => {
const s = stateRef.current;
s.isDragging = true;
s.velX = 0;
s.velY = 0;
(e.target as HTMLElement).setPointerCapture(e.pointerId);
if (areaRef.current) {
const rect = areaRef.current.getBoundingClientRect();
s.dragOffsetX = e.clientX - rect.left - rect.width / 2 - s.posX;
s.dragOffsetY = e.clientY - rect.top - rect.height / 2 - s.posY;
}
}, []);
useEffect(() => {
const onMove = (e: PointerEvent) => {
const s = stateRef.current;
if (!s.isDragging || !areaRef.current) return;
const rect = areaRef.current.getBoundingClientRect();
s.posX = e.clientX - rect.left - rect.width / 2 - s.dragOffsetX;
s.posY = e.clientY - rect.top - rect.height / 2 - s.dragOffsetY;
};
const onUp = () => {
stateRef.current.isDragging = false;
};
window.addEventListener("pointermove", onMove);
window.addEventListener("pointerup", onUp);
return () => {
window.removeEventListener("pointermove", onMove);
window.removeEventListener("pointerup", onUp);
};
}, []);
const sliders: { key: keyof SpringConfig; label: string; min: number; max: number }[] = [
{ key: "stiffness", label: "Stiffness", min: 20, max: 500 },
{ key: "damping", label: "Damping", min: 1, max: 40 },
{ key: "mass", label: "Mass", min: 1, max: 10 },
];
return (
<div
style={{
minHeight: "100vh",
background: "#0a0a0a",
display: "grid",
placeItems: "center",
padding: "2rem",
fontFamily: "system-ui, -apple-system, sans-serif",
color: "#e4e4e7",
overflow: "hidden",
}}
>
<div
style={{
width: "min(520px, 100%)",
display: "flex",
flexDirection: "column",
gap: "1.5rem",
alignItems: "center",
}}
>
<div style={{ textAlign: "center" }}>
<h2 style={{ fontSize: "1.25rem", fontWeight: 700, color: "#f4f4f5" }}>Spring Physics</h2>
<p style={{ fontSize: "0.8rem", color: "#52525b", marginTop: "0.25rem" }}>
Drag the ball and release — it springs back with real physics
</p>
</div>
<div
ref={areaRef}
style={{
width: "100%",
height: 340,
background: "rgba(255,255,255,0.02)",
border: "1px solid rgba(255,255,255,0.06)",
borderRadius: "1rem",
position: "relative",
overflow: "hidden",
}}
>
{/* Origin dot */}
<div
style={{
position: "absolute",
width: 12,
height: 12,
borderRadius: "50%",
background: "rgba(109,40,217,0.3)",
border: "2px solid rgba(109,40,217,0.5)",
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
pointerEvents: "none",
}}
/>
{/* Spring line */}
<div
ref={lineRef}
style={{
position: "absolute",
top: "50%",
left: "50%",
transformOrigin: "0 0",
height: 2,
background: "linear-gradient(90deg, rgba(109,40,217,0.4), rgba(109,40,217,0.1))",
pointerEvents: "none",
borderRadius: 1,
width: 0,
}}
/>
{/* Ball */}
<div
ref={ballRef}
onPointerDown={onPointerDown}
style={{
position: "absolute",
width: 56,
height: 56,
borderRadius: "50%",
background: "radial-gradient(circle at 35% 35%, #a78bfa, #6d28d9)",
boxShadow: "0 0 30px rgba(109,40,217,0.4), 0 0 60px rgba(109,40,217,0.15)",
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
cursor: "grab",
userSelect: "none",
touchAction: "none",
display: "grid",
placeItems: "center",
fontSize: "0.65rem",
fontWeight: 700,
color: "rgba(255,255,255,0.7)",
letterSpacing: "0.05em",
}}
>
DRAG
</div>
</div>
{/* Velocity bar */}
<div
style={{
width: "100%",
height: 4,
background: "rgba(255,255,255,0.04)",
borderRadius: 2,
overflow: "hidden",
}}
>
<div
ref={velBarRef}
style={{
height: "100%",
background: "linear-gradient(90deg, #6d28d9, #a78bfa)",
borderRadius: 2,
width: "0%",
transition: "width 0.05s",
}}
/>
</div>
{/* Controls */}
<div style={{ width: "100%", display: "flex", flexDirection: "column", gap: "0.75rem" }}>
{sliders.map(({ key, label, min, max }) => (
<div key={key} style={{ display: "flex", alignItems: "center", gap: "0.75rem" }}>
<span
style={{
fontSize: "0.75rem",
fontWeight: 600,
color: "#71717a",
minWidth: 80,
textTransform: "uppercase",
letterSpacing: "0.06em",
}}
>
{label}
</span>
<input
type="range"
min={min}
max={max}
value={config[key]}
onChange={(e) => setConfig((c) => ({ ...c, [key]: Number(e.target.value) }))}
style={{
flex: 1,
WebkitAppearance: "none",
appearance: "none" as never,
height: 4,
background: "rgba(255,255,255,0.08)",
borderRadius: 2,
outline: "none",
}}
/>
<span
style={{
fontSize: "0.75rem",
fontWeight: 600,
color: "#a78bfa",
minWidth: 40,
textAlign: "right",
fontVariantNumeric: "tabular-nums",
}}
>
{config[key]}
</span>
</div>
))}
</div>
</div>
</div>
);
}<script setup>
import { ref, onMounted, onUnmounted } from "vue";
const stiffness = ref(180);
const damping = ref(12);
const mass = ref(1);
const areaEl = ref(null);
const ballEl = ref(null);
const lineEl = ref(null);
const velBarEl = ref(null);
let state = {
posX: 0,
posY: 0,
velX: 0,
velY: 0,
isDragging: false,
dragOffsetX: 0,
dragOffsetY: 0,
};
let rafId = null;
let lastTime = 0;
function loop(now) {
const dt = Math.min((now - lastTime) / 1000, 0.032);
lastTime = now;
const s = state;
if (!s.isDragging) {
const fx = -stiffness.value * s.posX - damping.value * s.velX;
const fy = -stiffness.value * s.posY - damping.value * s.velY;
s.velX += (fx / mass.value) * dt;
s.velY += (fy / mass.value) * dt;
s.posX += s.velX * dt;
s.posY += s.velY * dt;
if (
Math.abs(s.posX) < 0.01 &&
Math.abs(s.posY) < 0.01 &&
Math.abs(s.velX) < 0.01 &&
Math.abs(s.velY) < 0.01
) {
s.posX = 0;
s.posY = 0;
s.velX = 0;
s.velY = 0;
}
}
if (ballEl.value) {
ballEl.value.style.transform = `translate(calc(-50% + ${s.posX}px), calc(-50% + ${s.posY}px))`;
}
if (lineEl.value) {
const dist = Math.sqrt(s.posX * s.posX + s.posY * s.posY);
const angle = Math.atan2(s.posY, s.posX) * (180 / Math.PI);
lineEl.value.style.width = `${dist}px`;
lineEl.value.style.transform = `rotate(${angle}deg)`;
}
if (velBarEl.value) {
const speed = Math.sqrt(s.velX * s.velX + s.velY * s.velY);
velBarEl.value.style.width = `${Math.min(speed / 8, 100)}%`;
}
rafId = requestAnimationFrame(loop);
}
function onPointerDown(e) {
state.isDragging = true;
state.velX = 0;
state.velY = 0;
e.target.setPointerCapture(e.pointerId);
if (areaEl.value) {
const rect = areaEl.value.getBoundingClientRect();
state.dragOffsetX = e.clientX - rect.left - rect.width / 2 - state.posX;
state.dragOffsetY = e.clientY - rect.top - rect.height / 2 - state.posY;
}
}
function onPointerMove(e) {
if (!state.isDragging || !areaEl.value) return;
const rect = areaEl.value.getBoundingClientRect();
state.posX = e.clientX - rect.left - rect.width / 2 - state.dragOffsetX;
state.posY = e.clientY - rect.top - rect.height / 2 - state.dragOffsetY;
}
function onPointerUp() {
state.isDragging = false;
}
onMounted(() => {
lastTime = performance.now();
rafId = requestAnimationFrame(loop);
window.addEventListener("pointermove", onPointerMove);
window.addEventListener("pointerup", onPointerUp);
});
onUnmounted(() => {
if (rafId) cancelAnimationFrame(rafId);
window.removeEventListener("pointermove", onPointerMove);
window.removeEventListener("pointerup", onPointerUp);
});
const sliders = [
{ key: "stiffness", label: "Stiffness", min: 20, max: 500 },
{ key: "damping", label: "Damping", min: 1, max: 40 },
{ key: "mass", label: "Mass", min: 1, max: 10 },
];
function getVal(key) {
if (key === "stiffness") return stiffness.value;
if (key === "damping") return damping.value;
return mass.value;
}
function setVal(key, v) {
if (key === "stiffness") stiffness.value = Number(v);
else if (key === "damping") damping.value = Number(v);
else mass.value = Number(v);
}
</script>
<template>
<div style="min-height:100vh;background:#0a0a0a;display:grid;place-items:center;padding:2rem;font-family:system-ui,-apple-system,sans-serif;color:#e4e4e7;overflow:hidden">
<div style="width:min(520px,100%);display:flex;flex-direction:column;gap:1.5rem;align-items:center">
<div style="text-align:center">
<h2 style="font-size:1.25rem;font-weight:700;color:#f4f4f5">Spring Physics</h2>
<p style="font-size:0.8rem;color:#52525b;margin-top:0.25rem">Drag the ball and release -- it springs back with real physics</p>
</div>
<div
ref="areaEl"
style="width:100%;height:340px;background:rgba(255,255,255,0.02);border:1px solid rgba(255,255,255,0.06);border-radius:1rem;position:relative;overflow:hidden"
>
<!-- Origin dot -->
<div style="position:absolute;width:12px;height:12px;border-radius:50%;background:rgba(109,40,217,0.3);border:2px solid rgba(109,40,217,0.5);top:50%;left:50%;transform:translate(-50%,-50%);pointer-events:none"></div>
<!-- Spring line -->
<div
ref="lineEl"
style="position:absolute;top:50%;left:50%;transform-origin:0 0;height:2px;background:linear-gradient(90deg,rgba(109,40,217,0.4),rgba(109,40,217,0.1));pointer-events:none;border-radius:1px;width:0"
></div>
<!-- Ball -->
<div
ref="ballEl"
@pointerdown="onPointerDown"
style="position:absolute;width:56px;height:56px;border-radius:50%;background:radial-gradient(circle at 35% 35%,#a78bfa,#6d28d9);box-shadow:0 0 30px rgba(109,40,217,0.4),0 0 60px rgba(109,40,217,0.15);top:50%;left:50%;transform:translate(-50%,-50%);cursor:grab;user-select:none;touch-action:none;display:grid;place-items:center;font-size:0.65rem;font-weight:700;color:rgba(255,255,255,0.7);letter-spacing:0.05em"
>DRAG</div>
</div>
<!-- Velocity bar -->
<div style="width:100%;height:4px;background:rgba(255,255,255,0.04);border-radius:2px;overflow:hidden">
<div
ref="velBarEl"
style="height:100%;background:linear-gradient(90deg,#6d28d9,#a78bfa);border-radius:2px;width:0%;transition:width 0.05s"
></div>
</div>
<!-- Controls -->
<div style="width:100%;display:flex;flex-direction:column;gap:0.75rem">
<div v-for="s in sliders" :key="s.key" style="display:flex;align-items:center;gap:0.75rem">
<span style="font-size:0.75rem;font-weight:600;color:#71717a;min-width:80px;text-transform:uppercase;letter-spacing:0.06em">{{ s.label }}</span>
<input
type="range"
:min="s.min"
:max="s.max"
:value="getVal(s.key)"
@input="(e) => setVal(s.key, e.target.value)"
style="flex:1;height:4px;background:rgba(255,255,255,0.08);border-radius:2px;outline:none;-webkit-appearance:none;appearance:none"
/>
<span style="font-size:0.75rem;font-weight:600;color:#a78bfa;min-width:40px;text-align:right;font-variant-numeric:tabular-nums">{{ getVal(s.key) }}</span>
</div>
</div>
</div>
</div>
</template><script>
import { onMount, onDestroy } from "svelte";
let stiffnessVal = 180;
let dampingVal = 12;
let massVal = 1;
let areaEl;
let ballEl;
let lineEl;
let velBarEl;
let state = {
posX: 0,
posY: 0,
velX: 0,
velY: 0,
isDragging: false,
dragOffsetX: 0,
dragOffsetY: 0,
};
let rafId = null;
let lastTime = 0;
function loop(now) {
const dt = Math.min((now - lastTime) / 1000, 0.032);
lastTime = now;
const s = state;
if (!s.isDragging) {
const fx = -stiffnessVal * s.posX - dampingVal * s.velX;
const fy = -stiffnessVal * s.posY - dampingVal * s.velY;
s.velX += (fx / massVal) * dt;
s.velY += (fy / massVal) * dt;
s.posX += s.velX * dt;
s.posY += s.velY * dt;
if (
Math.abs(s.posX) < 0.01 &&
Math.abs(s.posY) < 0.01 &&
Math.abs(s.velX) < 0.01 &&
Math.abs(s.velY) < 0.01
) {
s.posX = 0;
s.posY = 0;
s.velX = 0;
s.velY = 0;
}
}
if (ballEl) {
ballEl.style.transform = `translate(calc(-50% + ${s.posX}px), calc(-50% + ${s.posY}px))`;
}
if (lineEl) {
const dist = Math.sqrt(s.posX * s.posX + s.posY * s.posY);
const angle = Math.atan2(s.posY, s.posX) * (180 / Math.PI);
lineEl.style.width = `${dist}px`;
lineEl.style.transform = `rotate(${angle}deg)`;
}
if (velBarEl) {
const speed = Math.sqrt(s.velX * s.velX + s.velY * s.velY);
velBarEl.style.width = `${Math.min(speed / 8, 100)}%`;
}
rafId = requestAnimationFrame(loop);
}
function onPointerDown(e) {
state.isDragging = true;
state.velX = 0;
state.velY = 0;
e.target.setPointerCapture(e.pointerId);
if (areaEl) {
const rect = areaEl.getBoundingClientRect();
state.dragOffsetX = e.clientX - rect.left - rect.width / 2 - state.posX;
state.dragOffsetY = e.clientY - rect.top - rect.height / 2 - state.posY;
}
}
function onPointerMove(e) {
if (!state.isDragging || !areaEl) return;
const rect = areaEl.getBoundingClientRect();
state.posX = e.clientX - rect.left - rect.width / 2 - state.dragOffsetX;
state.posY = e.clientY - rect.top - rect.height / 2 - state.dragOffsetY;
}
function onPointerUp() {
state.isDragging = false;
}
onMount(() => {
lastTime = performance.now();
rafId = requestAnimationFrame(loop);
window.addEventListener("pointermove", onPointerMove);
window.addEventListener("pointerup", onPointerUp);
});
onDestroy(() => {
if (rafId) cancelAnimationFrame(rafId);
window.removeEventListener("pointermove", onPointerMove);
window.removeEventListener("pointerup", onPointerUp);
});
</script>
<div style="min-height:100vh;background:#0a0a0a;display:grid;place-items:center;padding:2rem;font-family:system-ui,-apple-system,sans-serif;color:#e4e4e7;overflow:hidden">
<div style="width:min(520px,100%);display:flex;flex-direction:column;gap:1.5rem;align-items:center">
<div style="text-align:center">
<h2 style="font-size:1.25rem;font-weight:700;color:#f4f4f5">Spring Physics</h2>
<p style="font-size:0.8rem;color:#52525b;margin-top:0.25rem">Drag the ball and release -- it springs back with real physics</p>
</div>
<div
bind:this={areaEl}
style="width:100%;height:340px;background:rgba(255,255,255,0.02);border:1px solid rgba(255,255,255,0.06);border-radius:1rem;position:relative;overflow:hidden"
>
<!-- Origin dot -->
<div style="position:absolute;width:12px;height:12px;border-radius:50%;background:rgba(109,40,217,0.3);border:2px solid rgba(109,40,217,0.5);top:50%;left:50%;transform:translate(-50%,-50%);pointer-events:none"></div>
<!-- Spring line -->
<div
bind:this={lineEl}
style="position:absolute;top:50%;left:50%;transform-origin:0 0;height:2px;background:linear-gradient(90deg,rgba(109,40,217,0.4),rgba(109,40,217,0.1));pointer-events:none;border-radius:1px;width:0"
></div>
<!-- Ball -->
<div
bind:this={ballEl}
on:pointerdown={onPointerDown}
style="position:absolute;width:56px;height:56px;border-radius:50%;background:radial-gradient(circle at 35% 35%,#a78bfa,#6d28d9);box-shadow:0 0 30px rgba(109,40,217,0.4),0 0 60px rgba(109,40,217,0.15);top:50%;left:50%;transform:translate(-50%,-50%);cursor:grab;user-select:none;touch-action:none;display:grid;place-items:center;font-size:0.65rem;font-weight:700;color:rgba(255,255,255,0.7);letter-spacing:0.05em"
>DRAG</div>
</div>
<!-- Velocity bar -->
<div style="width:100%;height:4px;background:rgba(255,255,255,0.04);border-radius:2px;overflow:hidden">
<div
bind:this={velBarEl}
style="height:100%;background:linear-gradient(90deg,#6d28d9,#a78bfa);border-radius:2px;width:0%;transition:width 0.05s"
></div>
</div>
<!-- Controls -->
<div style="width:100%;display:flex;flex-direction:column;gap:0.75rem">
<div style="display:flex;align-items:center;gap:0.75rem">
<span style="font-size:0.75rem;font-weight:600;color:#71717a;min-width:80px;text-transform:uppercase;letter-spacing:0.06em">Stiffness</span>
<input type="range" min="20" max="500" bind:value={stiffnessVal} style="flex:1;height:4px;background:rgba(255,255,255,0.08);border-radius:2px;outline:none;-webkit-appearance:none;appearance:none" />
<span style="font-size:0.75rem;font-weight:600;color:#a78bfa;min-width:40px;text-align:right;font-variant-numeric:tabular-nums">{stiffnessVal}</span>
</div>
<div style="display:flex;align-items:center;gap:0.75rem">
<span style="font-size:0.75rem;font-weight:600;color:#71717a;min-width:80px;text-transform:uppercase;letter-spacing:0.06em">Damping</span>
<input type="range" min="1" max="40" bind:value={dampingVal} style="flex:1;height:4px;background:rgba(255,255,255,0.08);border-radius:2px;outline:none;-webkit-appearance:none;appearance:none" />
<span style="font-size:0.75rem;font-weight:600;color:#a78bfa;min-width:40px;text-align:right;font-variant-numeric:tabular-nums">{dampingVal}</span>
</div>
<div style="display:flex;align-items:center;gap:0.75rem">
<span style="font-size:0.75rem;font-weight:600;color:#71717a;min-width:80px;text-transform:uppercase;letter-spacing:0.06em">Mass</span>
<input type="range" min="1" max="10" bind:value={massVal} style="flex:1;height:4px;background:rgba(255,255,255,0.08);border-radius:2px;outline:none;-webkit-appearance:none;appearance:none" />
<span style="font-size:0.75rem;font-weight:600;color:#a78bfa;min-width:40px;text-align:right;font-variant-numeric:tabular-nums">{massVal}</span>
</div>
</div>
</div>
</div>Spring Physics
A hands-on spring physics demo. Drag the element and release it — it bounces back to its origin following real spring dynamics.
How it works
- Hooke’s Law —
F = -kx - cvwherekis stiffness,cis damping,xis displacement,vis velocity - On each
requestAnimationFrametick the force is calculated, velocity updated, and position integrated - Mouse/touch drag overrides the position; on release the spring simulation takes over
- Configurable sliders let you tune stiffness (how snappy) and damping (how quickly oscillations die)
Use cases
- Pull-to-refresh interactions
- Draggable cards that snap back
- Bouncy UI transitions
- Physics-based scroll indicators