Spring Tuner
by sloprun seed
2026-08-14
0 reported it broke · the ▶ count is distinct visitors who ran it, counted once each, anonymously
Good interface motion isn't a duration you picked off a menu — it's a weight on a spring with a brake on it, and three numbers decide everything it does. Drag stiffness, damping and mass and a card re-throws itself between two slots while the same motion is drawn beside it as a curve, with overshoot, settle time and bounce count updating live. It opens on snappy — 20% past the target, done in just over half a second — so try bouncy, then pull damping down to 4 and watch settle time balloon toward two seconds while the curve refuses to sit still. The linear() easing at the bottom is that same spring, solved in closed form and written out so any browser can replay it with no JavaScript at all.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="color-scheme" content="light dark">
<title>Spring Tuner — sloprun.dev</title>
<link rel="icon" href="data:,">
<style>
/* ── sloprun design tokens (verbatim from demos/tokens.css) ───────────────── */
:root {
--bg: #F6F6F3; /* neutral paper, not cream */
--surface: #FFFFFF;
--ink: #1A1D21;
--muted: #5A6068;
--line: #E3E2DC;
--accent: #C05600; /* phosphor amber, darkened for light bg */
--accent-soft: #FFF3E6;
--run: #1A7F37; /* earned green */
--run-soft: #E7F4EA;
--danger: #C93C37;
--radius: 10px;
--font-sans: system-ui, -apple-system, "Segoe UI", sans-serif;
--font-mono: ui-monospace, "SF Mono", "Cascadia Code", Menlo, Consolas, monospace;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--bg: #14161A; --surface: #1C1F26; --ink: #E8E6E1; --muted: #9AA0A8;
--line: #2A2E36; --accent: #FFB454; --accent-soft: #2A2118;
--run: #3FB950; --run-soft: #16281B; --danger: #F47067;
}
}
:root[data-theme="dark"] {
--bg: #14161A; --surface: #1C1F26; --ink: #E8E6E1; --muted: #9AA0A8;
--line: #2A2E36; --accent: #FFB454; --accent-soft: #2A2118;
--run: #3FB950; --run-soft: #16281B; --danger: #F47067;
}
body { background: var(--bg); color: var(--ink); font-family: var(--font-sans); }
/* ── end tokens ───────────────────────────────────────────────────────────── */
/* demo-local additions (alpha values; tokens are opaque hex) */
:root { --scrim: rgba(246,246,243,.86); --shadow: 0 1px 2px rgba(26,29,33,.05); --cast: 26,29,33; }
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) { --scrim: rgba(20,22,26,.9); --shadow: 0 1px 2px rgba(0,0,0,.35); --cast: 0,0,0; }
}
:root[data-theme="dark"] { --scrim: rgba(20,22,26,.9); --shadow: 0 1px 2px rgba(0,0,0,.35); --cast: 0,0,0; }
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; }
html { -webkit-text-size-adjust: 100%; }
body { min-height: 100%; overflow-x: hidden; }
.wrap { max-width: 1140px; margin: 0 auto; padding: 22px 16px 26px;
display: flex; flex-direction: column; gap: 16px; }
h1 { margin: 0 0 6px; font-size: clamp(21px, 3.6vw, 29px); letter-spacing: -.021em; line-height: 1.15; }
.lede { margin: 0; max-width: 70ch; color: var(--muted); font-size: 14.5px; line-height: 1.55; }
.lede b { color: var(--ink); font-weight: 600; }
.stage { display: grid; grid-template-columns: minmax(0,1fr) 316px; gap: 16px; align-items: stretch; }
.panel { align-self: start; }
@media (max-width: 920px) { .stage { grid-template-columns: minmax(0,1fr); } }
.left, .panel { display: flex; flex-direction: column; gap: 12px; min-width: 0; }
.card { background: var(--surface); border: 1px solid var(--line);
border-radius: var(--radius); padding: 12px 13px; box-shadow: var(--shadow); }
.card h2 { margin: 0 0 10px; font-family: var(--font-mono); font-size: 10.5px;
letter-spacing: .1em; text-transform: uppercase; color: var(--muted); font-weight: 600; }
/* ── the stage the card moves across ─────────────────────────────────────── */
.track {
--cw: clamp(80px, 22%, 126px); --ch: 76px; --e: 0;
position: relative; overflow: hidden; display: block; width: 100%;
border: 1px solid var(--line); border-radius: var(--radius);
background-color: var(--surface);
background-image: radial-gradient(var(--line) 1.1px, transparent 1.2px);
background-size: 28px 28px; background-position: -14px -14px;
height: clamp(168px, 30vw, 214px); box-shadow: var(--shadow);
cursor: pointer; -webkit-tap-highlight-color: transparent; touch-action: manipulation;
user-select: none; -webkit-user-select: none;
}
@media (max-width: 420px) { .track { --ch: 66px; height: 156px; } }
.track:focus { outline: none; }
.track:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.track::after { content: ""; position: absolute; left: 25%; right: 25%; top: 50%;
height: 1px; background: var(--line); z-index: 0; }
.slot { position: absolute; top: 50%; width: var(--cw); height: var(--ch); z-index: 1;
transform: translate(-50%, -50%); border: 1.5px dashed var(--line); border-radius: 9px;
transition: border-color .18s ease, background-color .18s ease; }
.slot.a { left: 25%; } .slot.b { left: 75%; }
.slot.on { border-color: var(--accent); background: var(--accent-soft); }
.slot .tag { position: absolute; left: 8px; top: 6px;
font-family: var(--font-mono); font-size: 11px; letter-spacing: .12em; color: var(--muted); }
.slot.on .tag { color: var(--accent); }
.obj { position: absolute; top: 50%; left: 25%; width: var(--cw); height: var(--ch); z-index: 2;
transform: translate(-50%, -50%); background: var(--surface);
border: 1px solid var(--line); border-radius: 9px;
padding: 0 11px; display: flex; flex-direction: column; justify-content: center; gap: 7px;
box-shadow: 0 calc(3px + var(--e) * 9px) calc(9px + var(--e) * 20px) rgba(var(--cast), calc(.09 + var(--e) * .12)); }
.obj .ln { display: block; height: 6px; border-radius: 3px; background: var(--line); }
.obj .ln.hd { height: 7px; width: 44%; background: var(--accent); }
.obj .ln.w2 { width: 100%; } .obj .ln.w3 { width: 64%; }
@media (max-width: 420px) { .obj { padding: 0 9px; gap: 6px; } .obj .ln { height: 5px; } }
.hud { position: absolute; left: 10px; top: 10px; display: flex; flex-wrap: wrap; gap: 4px 6px;
pointer-events: none; z-index: 3; max-width: calc(100% - 20px); }
.hud span { font-family: var(--font-mono); font-size: 10.5px; letter-spacing: .04em;
color: var(--muted); background: var(--scrim); border: 1px solid var(--line);
border-radius: 999px; padding: 3px 8px; font-variant-numeric: tabular-nums; white-space: nowrap; }
.hud span b { color: var(--ink); font-weight: 600; }
.hint { position: absolute; right: 10px; bottom: 10px; z-index: 3; pointer-events: none;
font-family: var(--font-mono); font-size: 11px; color: var(--muted); background: var(--scrim);
border: 1px solid var(--line); border-radius: 999px; padding: 5px 10px;
transition: opacity .5s ease; max-width: calc(100% - 20px); }
.hint.gone { opacity: 0; }
/* ── plot ────────────────────────────────────────────────────────────────── */
.plotcard { flex: 1 1 auto; display: flex; flex-direction: column; }
.plotwrap { position: relative; flex: 1 1 auto; min-height: 210px; max-height: 340px; }
@media (max-width: 560px) { .plotwrap { min-height: 178px; } }
canvas { display: block; width: 100%; height: 100%; }
.plotwrap canvas { position: absolute; inset: 0; }
.legend { display: flex; flex-wrap: wrap; gap: 4px 14px; margin-top: 9px;
font-family: var(--font-mono); font-size: 10px; letter-spacing: .06em;
text-transform: uppercase; color: var(--muted); }
.legend i { display: inline-block; width: 14px; height: 0; vertical-align: middle;
border-top: 2px solid var(--accent); margin-right: 6px; }
.legend .rest i { border-top: 2px dashed var(--muted); }
.legend .band i { height: 8px; border: 0; border-radius: 2px; background: var(--run-soft);
box-shadow: inset 0 0 0 1px var(--line); }
.legend .done i { border-top-color: var(--run); }
/* ── stats ───────────────────────────────────────────────────────────────── */
.stats { display: grid; grid-template-columns: repeat(3, minmax(0,1fr)); gap: 10px; }
.stat { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.stat .v { font-family: var(--font-mono); font-size: clamp(16px, 4.2vw, 20px); line-height: 1.05;
font-variant-numeric: tabular-nums; letter-spacing: -.02em; white-space: nowrap; }
.stat .v .u { font-size: 11px; color: var(--muted); margin-left: 2px; letter-spacing: 0; }
.stat .k { font-family: var(--font-mono); font-size: 9.5px; letter-spacing: .09em;
text-transform: uppercase; color: var(--muted); }
.reg { font-family: var(--font-mono); font-size: 11px; letter-spacing: .02em;
color: var(--muted); margin-top: 10px; line-height: 1.5; }
.reg b { color: var(--ink); font-weight: 600; }
.pill { display: inline-flex; align-items: center; gap: 6px; margin-top: 11px;
font-family: var(--font-mono); font-size: 11px; letter-spacing: .04em;
padding: 3px 9px 3px 7px; border-radius: 999px;
border: 1px solid var(--line); color: var(--muted); background: var(--bg); }
.pill .dot { width: 7px; height: 7px; border-radius: 50%; background: var(--muted); flex: none; }
.pill.on { color: var(--run); background: var(--run-soft); border-color: transparent; }
.pill.on .dot { background: var(--run); }
.pill.work { color: var(--accent); background: var(--accent-soft); border-color: transparent; }
.pill.work .dot { background: var(--accent); }
.cap { margin: 9px 0 0; font-size: 11.5px; line-height: 1.45; color: var(--muted); }
.cap b { color: var(--ink); font-weight: 600; }
/* ── controls ────────────────────────────────────────────────────────────── */
.ctl { display: block; margin-bottom: 14px; }
.ctl:last-child { margin-bottom: 2px; }
.ctl-top { display: flex; justify-content: space-between; align-items: baseline; gap: 8px; margin-bottom: 3px; }
.ctl-top label { font-size: 13px; }
.ctl-top .val { font-family: var(--font-mono); font-size: 12px; color: var(--muted);
font-variant-numeric: tabular-nums; }
.ctl .desc { display: block; font-size: 11.5px; color: var(--muted); line-height: 1.4; margin-top: 3px; }
input[type=range] { -webkit-appearance: none; appearance: none; display: block;
width: 100%; height: 20px; margin: 0; background: transparent; cursor: pointer; }
input[type=range]::-webkit-slider-runnable-track { height: 4px; border-radius: 2px; background: var(--line); }
input[type=range]::-webkit-slider-thumb { -webkit-appearance: none; width: 15px; height: 15px;
margin-top: -5.5px; border-radius: 50%; background: var(--accent);
border: 2px solid var(--surface); box-shadow: 0 0 0 1px var(--line); }
input[type=range]::-moz-range-track { height: 4px; border-radius: 2px; background: var(--line); }
input[type=range]::-moz-range-thumb { width: 13px; height: 13px; border-radius: 50%;
background: var(--accent); border: 2px solid var(--surface); box-shadow: 0 0 0 1px var(--line); }
input[type=range]:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 6px; }
button { font: inherit; font-size: 13px; font-family: var(--font-sans);
color: var(--ink); background: var(--surface); border: 1px solid var(--line);
border-radius: 8px; padding: 7px 11px; cursor: pointer; line-height: 1.2;
transition: border-color .12s ease, color .12s ease, background .12s ease; }
button:hover:not(:disabled) { border-color: var(--accent); color: var(--accent); }
button:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
button.primary { background: var(--accent); color: var(--surface); border-color: var(--accent); font-weight: 600; }
button.primary:hover:not(:disabled) { color: var(--surface); filter: brightness(1.07); }
.row { display: flex; flex-wrap: wrap; gap: 7px; align-items: center; }
.row > button { flex: 1 1 auto; }
.chips { display: flex; flex-wrap: wrap; gap: 7px; }
.chips button { flex: 1 1 auto; font-family: var(--font-mono); font-size: 11.5px;
letter-spacing: .02em; padding: 6px 9px; }
.chips button[aria-pressed="true"] { background: var(--accent-soft); color: var(--accent); border-color: var(--accent); }
.code { margin: 0; font-family: var(--font-mono); font-size: 11.5px; line-height: 1.6;
color: var(--ink); background: var(--bg); border: 1px solid var(--line); border-radius: 8px;
padding: 9px 10px; white-space: pre-wrap; word-break: break-word;
max-height: 118px; overflow: auto; }
.code:focus { outline: none; }
.code:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.code .p { color: var(--muted); }
.copyrow { display: flex; align-items: center; gap: 9px; margin-top: 9px; }
.copyrow .st { font-family: var(--font-mono); font-size: 11px; color: var(--muted); }
.copyrow .st.ok { color: var(--run); }
footer { font-family: var(--font-mono); font-size: 11px; color: var(--muted);
letter-spacing: .05em; padding-top: 2px; }
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; }
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { transition-duration: .001ms !important; animation-duration: .001ms !important; }
}
</style>
</head>
<body>
<div class="wrap">
<header>
<h1>Spring Tuner</h1>
<p class="lede">
Nice interface motion isn't a duration and a curve you picked from a menu — it's a little
spring with a weight on the end. Three numbers decide everything it does.
<b>Click the stage</b> to throw the card at the other slot, then drag the three knobs and
watch the same motion redraw itself as a line.
</p>
</header>
<main class="stage">
<div class="left">
<div class="track" id="track" role="button" tabindex="0"
aria-label="Stage. Activate to snap the card to the other slot.">
<div class="slot a" id="slotA" aria-hidden="true"><span class="tag">A</span></div>
<div class="slot b on" id="slotB" aria-hidden="true"><span class="tag">B</span></div>
<div class="obj" id="obj" aria-hidden="true">
<span class="ln hd"></span>
<span class="ln w2"></span>
<span class="ln w3"></span>
</div>
<div class="hud" aria-hidden="true">
<span>POS <b id="hPos">0.0</b> %</span>
<span>SPEED <b id="hVel">0</b> %/s</span>
</div>
<div class="hint" id="hint">click the stage · or press space</div>
</div>
<section class="card plotcard" aria-label="Travel over time">
<h2>Travel over time</h2>
<div class="plotwrap">
<canvas id="plot" role="img" aria-label="Line chart of how far the card has travelled against time, with the resting position drawn as a dashed line and a shaded band showing the last two percent.">
A chart of the card's travel over time.
</canvas>
</div>
<div class="legend" aria-hidden="true">
<span><i></i>travel</span>
<span class="rest"><i></i>slot it is heading to</span>
<span class="band"><i></i>within 2%</span>
<span class="done"><i></i>settled</span>
</div>
</section>
<section class="card" aria-label="How this spring behaves">
<h2>How this spring behaves</h2>
<div class="stats">
<div class="stat"><span class="v" id="sOver">—</span><span class="k">overshoot</span></div>
<div class="stat"><span class="v" id="sSettle">—</span><span class="k">settle time</span></div>
<div class="stat"><span class="v" id="sBounce">—</span><span class="k">bounces</span></div>
</div>
<div class="reg" id="reg"></div>
<span class="pill" id="pill"><span class="dot"></span><span id="pillText">ready</span></span>
<p class="cap" id="cap">
<b>Settled</b> is the moment it stops leaving the shaded band — 2% of the trip either side
of the slot. That is the number that matters: it is how long the motion <i>feels</i> like it lasts.
</p>
</section>
</div>
<div class="panel">
<section class="card" aria-label="Presets">
<h2>Start from a feel</h2>
<div class="chips" id="presets" role="group" aria-label="Spring presets">
<button type="button" data-p="0" aria-pressed="false">gentle</button>
<button type="button" data-p="1" aria-pressed="true">snappy</button>
<button type="button" data-p="2" aria-pressed="false">bouncy</button>
<button type="button" data-p="3" aria-pressed="false">critically damped</button>
</div>
<div class="row" style="margin-top:9px">
<button type="button" class="primary" id="snapBtn">Snap it</button>
<button type="button" id="loopBtn" aria-pressed="true">Auto-replay: on</button>
</div>
<p class="cap" id="rmNote" hidden>
Reduced motion is on, so the card moves without animating and auto-replay stays off.
The curve still shows the whole response.
</p>
</section>
<section class="card" aria-label="The three knobs">
<h2>The three knobs</h2>
<div class="ctl">
<div class="ctl-top">
<label for="k">Stiffness</label><span class="val" id="kV" aria-hidden="true">240</span>
</div>
<input type="range" id="k" min="40" max="400" step="1" value="240" aria-describedby="kD">
<span class="desc" id="kD">How hard the spring pulls. More stiffness, faster trip — and a harder arrival.</span>
</div>
<div class="ctl">
<div class="ctl-top">
<label for="c">Damping</label><span class="val" id="cV" aria-hidden="true">14.0</span>
</div>
<input type="range" id="c" min="2" max="60" step="0.5" value="14" aria-describedby="cD">
<span class="desc" id="cD">The brake. Too little and it wobbles for ages; too much and it crawls in.</span>
</div>
<div class="ctl">
<div class="ctl-top">
<label for="m">Mass</label><span class="val" id="mV" aria-hidden="true">1.00</span>
</div>
<input type="range" id="m" min="0.2" max="3" step="0.05" value="1" aria-describedby="mD">
<span class="desc" id="mD">How heavy the card feels. Weight slows it down and makes it harder to stop.</span>
</div>
</section>
<section class="card" aria-label="CSS for this spring">
<h2>Take it with you</h2>
<pre class="code" id="code" tabindex="0" aria-label="Generated CSS transition"><span class="p">transition: transform </span><span id="codeVal"></span></pre>
<div class="copyrow">
<button type="button" id="copyBtn">Copy CSS</button>
<span class="st" id="copySt" role="status"></span>
</div>
<p class="cap">Same spring, written as an easing curve any browser can replay on its own.</p>
</section>
</div>
</main>
<footer>demo · sloprun.dev</footer>
</div>
<div class="sr-only" role="status" aria-live="polite" id="live"></div>
<script>
(function () {
"use strict";
var root = document.documentElement;
var mqDark = window.matchMedia ? matchMedia("(prefers-color-scheme: dark)") : null;
var mqRM = window.matchMedia ? matchMedia("(prefers-reduced-motion: reduce)") : null;
var reduced = !!(mqRM && mqRM.matches);
function $(id) { return document.getElementById(id); }
var track = $("track"), obj = $("obj"), slotA = $("slotA"), slotB = $("slotB");
var hPos = $("hPos"), hVel = $("hVel"), hint = $("hint");
var plot = $("plot");
var pctx = plot.getContext ? plot.getContext("2d") : null;
/* no 2D canvas is a reason to lose the curve, not the whole demo — the card,
the numbers and the exported easing all still work without it */
if (!pctx) {
var pcard = document.querySelector(".plotcard");
if (pcard) pcard.innerHTML = '<h2>Travel over time</h2>'
+ '<p class="cap" style="margin:0">This browser can’t draw the curve. '
+ 'The card and the numbers below still work.</p>';
}
var sOver = $("sOver"), sSettle = $("sSettle"), sBounce = $("sBounce");
var reg = $("reg"), pill = $("pill"), pillText = $("pillText"), live = $("live");
var presetsEl = $("presets"), snapBtn = $("snapBtn"), loopBtn = $("loopBtn"), rmNote = $("rmNote");
var codeVal = $("codeVal"), codeEl = $("code"), copyBtn = $("copyBtn"), copySt = $("copySt");
var sl = { k: $("k"), c: $("c"), m: $("m") };
var lb = { k: $("kV"), c: $("cV"), m: $("mV") };
/* ── colours read straight from the tokens, so both themes just work ────── */
var COL = {
surface: [255,255,255], bg: [246,246,243], ink: [26,29,33], muted: [90,96,104],
line: [227,226,220], accent: [192,86,0], run: [26,127,55], danger: [201,60,55]
};
function hexToRgb(h) {
h = String(h).trim();
if (h.charAt(0) === "#") h = h.slice(1);
if (h.length === 3) h = h.charAt(0)+h.charAt(0)+h.charAt(1)+h.charAt(1)+h.charAt(2)+h.charAt(2);
var n = parseInt(h, 16);
if (h.length !== 6 || isNaN(n)) return null;
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
}
function tok(name, fb) { return hexToRgb(getComputedStyle(root).getPropertyValue(name)) || fb; }
function rgba(c, a) { return "rgba(" + c[0] + "," + c[1] + "," + c[2] + "," + a + ")"; }
function readTheme() {
COL.surface = tok("--surface", COL.surface); COL.bg = tok("--bg", COL.bg);
COL.ink = tok("--ink", COL.ink); COL.muted = tok("--muted", COL.muted);
COL.line = tok("--line", COL.line); COL.accent = tok("--accent", COL.accent);
COL.run = tok("--run", COL.run); COL.danger = tok("--danger", COL.danger);
}
var MONO = 'ui-monospace, "SF Mono", Menlo, Consolas, monospace';
window.addEventListener("message", function (e) {
var d = e.data;
if (d && d.type === "sloprun:theme" && (d.theme === "light" || d.theme === "dark")) {
root.setAttribute("data-theme", d.theme);
readTheme(); drawPlot(headT);
}
});
function onScheme() { readTheme(); drawPlot(headT); }
if (mqDark) { if (mqDark.addEventListener) mqDark.addEventListener("change", onScheme);
else if (mqDark.addListener) mqDark.addListener(onScheme); }
/* a host may also flip data-theme on <html> directly, with no message —
without this the canvas would keep painting the previous theme's colours */
if (window.MutationObserver) {
new MutationObserver(onScheme).observe(root, { attributes: true, attributeFilter: ["data-theme"] });
}
function onRM() {
reduced = !!(mqRM && mqRM.matches);
rmNote.hidden = !reduced;
loopBtn.hidden = reduced;
if (reduced) { loop = false; setLoopBtn(); stopRun(); }
}
if (mqRM) { if (mqRM.addEventListener) mqRM.addEventListener("change", onRM);
else if (mqRM.addListener) mqRM.addListener(onRM); }
/* ── the spring ─────────────────────────────────────────────────────────────
One weight on one spring with one brake: m·x'' = -k(x - goal) - c·x'
That has an exact answer, so nothing here is a step-by-step approximation —
every point on the curve is evaluated directly, which is why the plot and the
moving card can never drift apart. */
var P = { k: 240, c: 14, m: 1 };
var PRESETS = [
{ name: "gentle", k: 80, c: 16, m: 1.5 },
{ name: "snappy", k: 240, c: 14, m: 1 },
{ name: "bouncy", k: 320, c: 9, m: 1 },
{ name: "critically damped", k: 196, c: 28, m: 1 }
];
var LADDER = [0.3,0.4,0.5,0.6,0.8,1,1.25,1.5,2,2.5,3,4,5,6,8,10,12,16,20,26];
var N = 420;
function solver(x0, v0, goal, wn, z) {
var u0 = x0 - goal;
if (Math.abs(z - 1) < 1e-6) { /* critically damped */
var A = u0, B = v0 + wn * u0;
return function (t) {
var e = Math.exp(-wn * t), u = A + B * t;
return [goal + e * u, e * (B - wn * u)];
};
}
if (z < 1) { /* underdamped */
var s = z * wn, wd = wn * Math.sqrt(1 - z * z);
var A1 = u0, B1 = (v0 + s * u0) / wd;
return function (t) {
var e = Math.exp(-s * t), co = Math.cos(wd * t), si = Math.sin(wd * t);
var u = A1 * co + B1 * si;
return [goal + e * u, e * (-s * u + wd * (-A1 * si + B1 * co))];
};
}
var rt = wn * Math.sqrt(z * z - 1); /* overdamped */
var r1 = -z * wn + rt, r2 = -z * wn - rt;
var C1 = (v0 - r2 * u0) / (r1 - r2), C2 = (r1 * u0 - v0) / (r1 - r2);
return function (t) {
var e1 = Math.exp(r1 * t), e2 = Math.exp(r2 * t);
return [goal + C1 * e1 + C2 * e2, C1 * r1 * e1 + C2 * r2 * e2];
};
}
/* `from` is where this flight BEGAN, which is not always where the solve begins:
retuning mid-flight re-solves from wherever the card happens to be. Measuring
travel against that leftover distance would rescale the plot on every slider
nudge — and blow it up to thousands of percent when the card is crossing the
slot, where the leftover distance is nearly zero. Travel, the 2% band and the
overshoot are all measured against the whole trip instead, so the axis holds
still while the spring is retuned under it. */
function makePlan(x0, v0, goal, prm, from) {
if (from === undefined || from === null) from = x0;
var wn = Math.sqrt(prm.k / prm.m);
var z = prm.c / (2 * Math.sqrt(prm.k * prm.m));
var sol = solver(x0, v0, goal, wn, z);
var span = goal - from, mag = Math.abs(span);
if (mag < 1e-9) { span = (goal - x0) || 1; mag = Math.abs(span); }
var band = Math.max(1e-6, 0.02 * mag);
var slow = (z > 1 + 1e-6) ? wn * (z - Math.sqrt(z * z - 1)) : Math.max(z * wn, 1e-3);
var idx = 0, est = Math.min(26, 5 / slow);
while (idx < LADDER.length - 1 && LADDER[idx] < est) idx++;
var ts = null, xs = null, win = 0, settle = 0;
for (var guard = 0; guard < 6; guard++) {
win = LADDER[idx];
ts = new Array(N); xs = new Array(N);
for (var i = 0; i < N; i++) { var t = win * i / (N - 1); ts[i] = t; xs[i] = sol(t)[0]; }
settle = 0;
for (var j = N - 1; j >= 0; j--) {
if (Math.abs(xs[j] - goal) > band) { settle = (j < N - 1) ? ts[j + 1] : win; break; }
}
if (settle > 0.88 * win && idx < LADDER.length - 1) { idx++; continue; }
if (idx > 0 && settle <= 0.88 * LADDER[idx - 1]) { idx--; continue; }
break;
}
/* keep moving (invisibly) until BOTH the gap and one frame's worth of travel are
under a fraction of a pixel, so parking the card on the slot at the end never
shows. Testing the gap alone lets the run end on a fast zero-crossing — the
card is level with the slot but still doing half a stage per second, and it
freezes dead in the middle of a swing. */
var tol = Math.max(1e-9, 0.0015 * mag), vtol = tol * 60;
var tEnd = win, tstp = win / 240, g2 = 0, stE;
while (g2++ < 720) {
stE = sol(tEnd);
if (Math.abs(stE[0] - goal) <= tol && Math.abs(stE[1]) <= vtol) break;
tEnd += tstp;
}
if (tEnd > win * 4) tEnd = win * 4;
/* travel as a percentage of the trip: 0 % where it started, 100 % at the slot */
var ps = new Array(N), pmin = 0, pmax = 0;
var sgn = span >= 0 ? 1 : -1;
for (var q = 0; q < N; q++) {
ps[q] = (xs[q] - from) / span * 100;
if (ps[q] < pmin) pmin = ps[q];
if (ps[q] > pmax) pmax = ps[q];
}
var over = 0, overIdx = -1, bounces = 0;
for (var r = 0; r < N; r++) {
var past = (xs[r] - goal) * sgn;
if (past > over) { over = past; overIdx = r; }
}
for (var b = 1; b < N - 1; b++) {
var a0 = Math.abs(xs[b-1] - goal), a1 = Math.abs(xs[b] - goal), a2 = Math.abs(xs[b+1] - goal);
if (a1 >= a0 && a1 > a2 && a1 > band) bounces++;
}
var overPct = over / mag * 100;
if (overPct < 0.05) { overPct = 0; overIdx = -1; }
return {
sol: sol, ts: ts, xs: xs, ps: ps, win: win, tEnd: tEnd, settle: settle,
over: overPct, overIdx: overIdx, bounces: bounces,
z: z, wn: wn, span: span, mag: mag, x0: x0, from: from, goal: goal,
period: z < 1 ? 2 * Math.PI / (wn * Math.sqrt(1 - z * z)) : 0,
pmin: pmin, pmax: pmax
};
}
/* ── state ──────────────────────────────────────────────────────────────── */
var goal = 1; /* 0 = slot A, 1 = slot B */
var cur = { x: 0, v: 0 };
var flightFrom = 0; /* where the current flight began — the yardstick for travel % */
var plan = null, canon = null;
var running = false, t0 = 0, raf = 0, loopTimer = 0, headT = null;
var loop = !reduced, touched = false;
function stopRun() {
if (raf) { cancelAnimationFrame(raf); raf = 0; }
if (loopTimer) { clearTimeout(loopTimer); loopTimer = 0; }
running = false;
}
function trigger() {
var wasRunning = running;
stopRun();
/* a click while it is already moving must visibly do something: throw it back */
if (wasRunning) goal = 1 - goal;
var x = cur.x, v = cur.v;
flightFrom = x;
plan = makePlan(x, v, goal, P, flightFrom);
if (reduced) {
cur.x = goal; cur.v = 0; goal = 1 - goal;
headT = plan.win; running = false;
paint(); setPill(false);
return;
}
running = true; t0 = (window.performance && performance.now) ? performance.now() : Date.now();
headT = 0; setPill(true);
raf = requestAnimationFrame(frame);
}
function now() { return (window.performance && performance.now) ? performance.now() : Date.now(); }
function frame() {
raf = 0;
var t = (now() - t0) / 1000;
var done = t >= plan.tEnd;
if (done) t = plan.tEnd;
var s = plan.sol(t);
cur.x = s[0]; cur.v = s[1];
headT = Math.min(t, plan.win);
paint();
if (done) endRun(); else raf = requestAnimationFrame(frame);
}
function endRun() {
running = false;
cur.x = plan.goal; cur.v = 0;
goal = 1 - plan.goal;
flightFrom = cur.x;
plan = makePlan(cur.x, 0, goal, P, flightFrom);
headT = null;
paint(); setPill(false);
if (loop && !reduced) loopTimer = setTimeout(function () { loopTimer = 0; trigger(); }, 520);
}
function setPill(moving) {
if (moving) {
pill.className = "pill work"; pillText.textContent = "moving";
} else {
pill.className = "pill on";
pillText.textContent = "settled in " + fmtSec(canon.settle) + " s";
}
}
/* ── painting the card ──────────────────────────────────────────────────── */
function paintCard() {
var w = track.clientWidth || 1;
var px = cur.x * w * 0.5;
/* a violent overshoot can fling the card past the edge of the stage, where
overflow:hidden would swallow it whole. Keep it on screen — the HUD and the
curve still report the true, unclamped position. */
var half = (obj.offsetWidth || 0) / 2;
var lo = half - w * 0.25, hi = w - half - w * 0.25;
if (hi < lo) { lo = hi = w * 0.25; }
if (px < lo) px = lo; else if (px > hi) px = hi;
obj.style.transform = "translate(-50%,-50%) translateX(" + px.toFixed(2) + "px)";
obj.style.setProperty("--e", Math.min(1, Math.abs(cur.v) / 6).toFixed(3));
hPos.textContent = (cur.x * 100).toFixed(1);
var vv = cur.v * 100;
hVel.textContent = (vv >= 0 ? "+" : "") + Math.round(vv);
slotA.className = "slot a" + (goal === 0 ? " on" : "");
slotB.className = "slot b" + (goal === 1 ? " on" : "");
}
function paint() { paintCard(); drawPlot(headT); }
/* ── plot ───────────────────────────────────────────────────────────────── */
var PW = 0, PH = 0;
function fit() {
var r = plot.getBoundingClientRect();
var w = Math.max(1, Math.round(r.width)), h = Math.max(1, Math.round(r.height));
var dpr = Math.min(2, window.devicePixelRatio || 1);
var nw = Math.round(w * dpr), nh = Math.round(h * dpr);
if (plot.width !== nw || plot.height !== nh) { plot.width = nw; plot.height = nh; }
pctx.setTransform(dpr, 0, 0, dpr, 0, 0);
PW = w; PH = h;
}
function fmtSec(s) { return s >= 10 ? s.toFixed(1) : s.toFixed(2); }
function tickText(t, s, last) {
var d = s >= 1 ? 0 : (s >= 0.5 ? 1 : 2);
return t.toFixed(d) + (last ? " s" : "");
}
function drawPlot(playT) {
if (!pctx) return;
fit();
var g = pctx, W = PW, H = PH;
g.clearRect(0, 0, W, H);
g.fillStyle = rgba(COL.surface, 1); g.fillRect(0, 0, W, H);
if (!plan) return;
var pl = W < 430 ? 34 : 42, pr = 14, pt = 18, pb = 24;
var iw = Math.max(10, W - pl - pr), ih = Math.max(10, H - pt - pb);
var ymin = Math.max(-160, Math.min(-8, plan.pmin - 8));
var ymax = Math.min(360, Math.max(112, plan.pmax + 10));
var win = plan.win;
function X(t) { return pl + t / win * iw; }
function Y(p) { return pt + (ymax - p) / (ymax - ymin) * ih; }
/* the "close enough" band, then the two reference lines */
g.fillStyle = rgba(COL.run, 0.13);
g.fillRect(pl, Y(102), iw, Math.max(1, Y(98) - Y(102)));
g.lineWidth = 1;
g.strokeStyle = rgba(COL.line, 1);
g.beginPath(); g.moveTo(pl, Math.round(Y(0)) + 0.5); g.lineTo(pl + iw, Math.round(Y(0)) + 0.5); g.stroke();
g.save();
g.setLineDash([4, 4]);
g.strokeStyle = rgba(COL.muted, 0.55);
g.beginPath(); g.moveTo(pl, Math.round(Y(100)) + 0.5); g.lineTo(pl + iw, Math.round(Y(100)) + 0.5); g.stroke();
g.restore();
/* time ticks */
var cand = [0.05,0.1,0.2,0.25,0.5,1,2,5,10], sstep = cand[cand.length - 1];
for (var ci = 0; ci < cand.length; ci++) { if (win / cand[ci] <= 6) { sstep = cand[ci]; break; } }
g.font = "10px " + MONO;
g.textAlign = "center"; g.textBaseline = "top";
for (var tv = sstep; tv < win - 1e-9; tv += sstep) {
var xx = Math.round(X(tv)) + 0.5;
g.strokeStyle = rgba(COL.line, 0.9);
g.beginPath(); g.moveTo(xx, pt); g.lineTo(xx, pt + ih); g.stroke();
g.fillStyle = rgba(COL.muted, 1);
g.fillText(tickText(tv, sstep, tv + sstep >= win - 1e-9), X(tv), pt + ih + 6);
}
/* travel labels */
g.textAlign = "right"; g.textBaseline = "middle";
g.font = "9.5px " + MONO;
g.fillStyle = rgba(COL.muted, 1);
g.fillText("100%", pl - 6, Y(100));
g.fillText("0%", pl - 6, Y(0));
/* the curve — amber until it settles, then green */
var ts = plan.ts, ps = plan.ps, si = N - 1;
for (var k2 = 0; k2 < N; k2++) { if (ts[k2] >= plan.settle) { si = k2; break; } }
g.save();
g.beginPath(); g.rect(pl, pt, iw, ih); g.clip(); /* retuning mid-flight can send
the curve far outside the frame; keep the paint inside the plot box */
g.lineWidth = 2; g.lineJoin = "round"; g.lineCap = "round";
g.strokeStyle = rgba(COL.accent, 1);
g.beginPath(); g.moveTo(X(ts[0]), Y(ps[0]));
for (var a = 1; a <= si; a++) g.lineTo(X(ts[a]), Y(ps[a]));
g.stroke();
if (si < N - 1) {
g.strokeStyle = rgba(COL.run, 1);
g.beginPath(); g.moveTo(X(ts[si]), Y(ps[si]));
for (var b2 = si + 1; b2 < N; b2++) g.lineTo(X(ts[b2]), Y(ps[b2]));
g.stroke();
}
g.restore();
/* where it settles */
if (plan.settle > 0 && plan.settle < win) {
var sx = Math.round(X(plan.settle)) + 0.5;
g.save();
g.setLineDash([3, 3]); g.lineWidth = 1; g.strokeStyle = rgba(COL.run, 0.75);
g.beginPath(); g.moveTo(sx, pt); g.lineTo(sx, pt + ih); g.stroke();
g.restore();
g.font = "10px " + MONO; g.fillStyle = rgba(COL.run, 1);
g.textBaseline = "top";
g.textAlign = "right";
g.fillText("settled " + fmtSec(plan.settle) + " s", pl + iw, pt - 13);
}
/* the high point of the overshoot */
var oy = plan.overIdx >= 0 ? Y(ps[plan.overIdx]) : -1;
if (plan.overIdx >= 0 && plan.over >= 0.5 && oy >= pt && oy <= pt + ih) {
var ox = X(ts[plan.overIdx]);
g.fillStyle = rgba(COL.accent, 1);
g.beginPath(); g.arc(ox, oy, 3, 0, Math.PI * 2); g.fill();
g.font = "10px " + MONO; g.textBaseline = "bottom";
var ol = "+" + plan.over.toFixed(1) + "%";
var half = g.measureText(ol).width / 2;
g.textAlign = "center";
g.fillText(ol, Math.min(pl + iw - half, Math.max(pl + half, ox)), Math.max(10, oy - 7));
}
/* playhead */
if (playT !== null && playT !== undefined) {
var hx = X(playT), st = plan.sol(playT);
var hp = (st[0] - plan.from) / plan.span * 100;
g.save();
g.beginPath(); g.rect(pl, pt, iw, ih); g.clip();
g.lineWidth = 1; g.strokeStyle = rgba(COL.ink, 0.20);
g.beginPath(); g.moveTo(Math.round(hx) + 0.5, pt); g.lineTo(Math.round(hx) + 0.5, pt + ih); g.stroke();
g.fillStyle = playT >= plan.settle ? rgba(COL.run, 1) : rgba(COL.accent, 1);
g.strokeStyle = rgba(COL.surface, 1); g.lineWidth = 2;
g.beginPath(); g.arc(hx, Y(hp), 4.5, 0, Math.PI * 2); g.fill(); g.stroke();
g.restore();
}
}
/* ── numbers ────────────────────────────────────────────────────────────── */
function setStat(el, value, unit) {
el.textContent = value;
if (unit) { var s = document.createElement("span"); s.className = "u"; s.textContent = unit; el.appendChild(s); }
}
function trim(v) {
var s = (Math.round(v * 1000) / 1000).toFixed(3);
s = s.replace(/0+$/, "").replace(/\.$/, "");
return s === "-0" ? "0" : s;
}
function announce(msg) { live.textContent = msg; }
function updateStats() {
setStat(sOver, canon.over.toFixed(1), "%");
setStat(sSettle, fmtSec(canon.settle), " s");
setStat(sBounce, String(canon.bounces), "");
var name = canon.z < 0.999 ? "underdamped" : (canon.z > 1.001 ? "overdamped" : "critically damped");
var bits = "<b>" + name + "</b> · damping ratio <b>" + canon.z.toFixed(2) + "</b> · ";
bits += canon.period > 0 ? "wobbles every <b>" + canon.period.toFixed(2) + " s</b>"
: "never wobbles";
reg.innerHTML = bits;
/* linear() draws straight lines between the stops it is given, so a wobbly
spring needs enough stops to resolve one wobble — sample too coarsely and
the exported easing is an aliased curve that looks nothing like this one. */
var D = canon.settle > 0.02 ? canon.settle : 0.2, pts = 26, out = [];
if (canon.period > 0) pts = Math.max(26, Math.min(150, Math.ceil(D / canon.period * 14) + 1));
for (var i = 0; i < pts; i++) {
var t = D * i / (pts - 1);
out.push(trim((canon.sol(t)[0] - canon.from) / canon.span));
}
out[0] = "0"; out[pts - 1] = "1";
codeVal.textContent = Math.round(D * 1000) + "ms linear(" + out.join(", ") + ");";
}
/* ── keeping everything in step ─────────────────────────────────────────── */
function refresh() {
canon = makePlan(0, 0, 1, P);
updateStats();
if (running) {
var s = plan.sol(Math.min(plan.tEnd, (now() - t0) / 1000));
cur.x = s[0]; cur.v = s[1];
if (Math.abs(goal - cur.x) < 0.02 && Math.abs(cur.v) < 0.5) {
/* it has effectively arrived — replanning a zero-length trip would blow the
percentages up. Land it, and let the new numbers drive the next throw. */
stopRun(); endRun(); return;
}
plan = makePlan(cur.x, cur.v, goal, P, flightFrom);
t0 = now(); headT = 0;
} else {
flightFrom = cur.x;
plan = makePlan(cur.x, 0, goal, P, flightFrom);
headT = null;
if (pill.className.indexOf("on") >= 0) pillText.textContent = "settled in " + fmtSec(canon.settle) + " s";
}
paint();
}
function replay() {
stopRun();
cur.x = 1 - goal; cur.v = 0;
paintCard();
trigger();
}
/* ── controls ───────────────────────────────────────────────────────────── */
function syncLabels() {
lb.k.textContent = P.k.toFixed(0);
lb.c.textContent = P.c.toFixed(1);
lb.m.textContent = P.m.toFixed(2);
}
function markPreset() {
var btns = presetsEl.getElementsByTagName("button");
for (var i = 0; i < btns.length; i++) {
var p = PRESETS[+btns[i].getAttribute("data-p")];
var on = p && Math.abs(p.k - P.k) < 1e-6 && Math.abs(p.c - P.c) < 1e-6 && Math.abs(p.m - P.m) < 1e-6;
btns[i].setAttribute("aria-pressed", on ? "true" : "false");
}
}
function setLoopBtn() {
loopBtn.setAttribute("aria-pressed", loop ? "true" : "false");
loopBtn.textContent = "Auto-replay: " + (loop ? "on" : "off");
}
function hideHint() { hint.classList.add("gone"); }
/* touch() also stands down the one-off opening throw — the visitor has taken over */
function touch() { hideHint(); touched = true; }
function onSlide() {
P.k = +sl.k.value; P.c = +sl.c.value; P.m = +sl.m.value;
syncLabels(); markPreset(); hideHint(); refresh();
}
sl.k.addEventListener("input", onSlide);
sl.c.addEventListener("input", onSlide);
sl.m.addEventListener("input", onSlide);
presetsEl.addEventListener("click", function (e) {
var n = e.target;
while (n && n !== presetsEl && !n.getAttribute) n = n.parentNode;
while (n && n !== presetsEl && n.getAttribute("data-p") === null) n = n.parentNode;
if (!n || n === presetsEl) return;
var p = PRESETS[+n.getAttribute("data-p")];
if (!p) return;
P.k = p.k; P.c = p.c; P.m = p.m;
sl.k.value = String(p.k); sl.c.value = String(p.c); sl.m.value = String(p.m);
syncLabels(); markPreset(); touch(); refresh();
announce(p.name + ": overshoot " + canon.over.toFixed(1) + " percent, settles in " + fmtSec(canon.settle) + " seconds.");
if (!reduced) replay();
});
track.addEventListener("click", function () { touch(); trigger(); });
track.addEventListener("keydown", function (e) {
var k = e.key;
if (k === " " || k === "Spacebar" || k === "Enter") { e.preventDefault(); touch(); trigger(); }
});
snapBtn.addEventListener("click", function () { touch(); trigger(); });
loopBtn.addEventListener("click", function () {
loop = !loop; setLoopBtn();
if (loopTimer) { clearTimeout(loopTimer); loopTimer = 0; }
if (loop && !running) loopTimer = setTimeout(function () { loopTimer = 0; trigger(); }, 260);
});
copyBtn.addEventListener("click", function () {
var txt = "transition: transform " + codeVal.textContent;
function done(ok) {
copySt.textContent = ok ? "copied" : "select the text above to copy";
copySt.className = ok ? "st ok" : "st";
setTimeout(function () { copySt.textContent = ""; copySt.className = "st"; }, 2600);
}
function fallback() {
var ok = false;
try {
var ta = document.createElement("textarea");
ta.value = txt; ta.setAttribute("readonly", "");
ta.style.position = "fixed"; ta.style.top = "-1000px"; ta.style.opacity = "0";
document.body.appendChild(ta); ta.select();
ok = document.execCommand("copy");
document.body.removeChild(ta);
} catch (e2) { ok = false; }
if (!ok) {
try {
var r = document.createRange(); r.selectNodeContents(codeEl);
var sel = window.getSelection(); sel.removeAllRanges(); sel.addRange(r);
} catch (e3) {}
}
done(ok);
}
try {
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(txt).then(function () { done(true); }, fallback);
return;
}
} catch (e) {}
fallback();
});
var rz = 0;
window.addEventListener("resize", function () {
if (rz) return;
rz = requestAnimationFrame(function () { rz = 0; paint(); });
});
/* ── boot: already tuned, already interesting ───────────────────────────── */
readTheme();
onRM(); setLoopBtn(); syncLabels(); markPreset();
canon = makePlan(0, 0, 1, P);
flightFrom = cur.x;
plan = makePlan(cur.x, 0, goal, P, flightFrom);
updateStats();
paint();
pill.className = "pill"; pillText.textContent = "ready";
if (!reduced) setTimeout(function () { if (!touched && !running) trigger(); }, 700);
})();
</script>
</body>
</html>
source-visible by construction · nothing is published here without its code
Comments
0 totalNo comments yet. If you ran it, say what happened.