Spring Viz

A spring-mass-damper system, simulated and plotted. A bob hanging off a wall-anchored spring of stiffness k and damping c obeys m·ẍ + c·ẋ + k·x = 0. Release the mass from a displacement and it oscillates, decays, or creeps depending on the damping ratio ζ = c / (2·sqrt(k·m)) — the same physics that gives gradient descent with momentum its overshoot, settling, and critical-damping regimes.

Spring-mass-damper visualisation.Spring-mass-damper system: stiffness 100.0 newtons per metre, damping 6.0 newton-seconds per metre, mass 1.00 kilograms. Regime underdamped (damping ratio ζ 0.30). At time 0.90 seconds the mass is at displacement -0.032 with velocity -0.487.
100
6.0
0.90s
x -0.032v -0.487ζ 0.30m 1.00regime underdamped
Customize
Stiffness
100
Damping
6.0
Mass
1.00
Time
0.90s
Playback

Installation

npx shadcn@latest add https://craftbits.dev/r/spring-viz.json

Usage

import { SpringViz } from "@craft-bits/core";
 
<SpringViz />

Drive stiffness and damping from outside:

const [k, setK] = useState(100);
const [c, setC] = useState(10);
 
<SpringViz
  stiffness={k}
  onStiffnessChange={setK}
  damping={c}
  onDampingChange={setC}
/>

Scrub the time cursor:

const [t, setT] = useState(0);
 
<SpringViz
  currentTime={t}
  onCurrentTimeChange={setT}
  duration={6}
/>

Autoplay in real time:

<SpringViz playing playSpeed={1} />

Understanding the component

  1. Two scalar state variables, integrated with semi-implicit Euler. Every simulation step blends the new acceleration a = -(k·x + c·v) / m into the velocity, then advances the position by v · dt. Semi-implicit Euler stays stable at the high k/m that would blow up forward Euler — the rendered waveform matches the analytic closed form x(t) = A · e^(-ζω₀t) · cos(ω_d t + φ) to within a pixel.
  2. The displacement waveform is precomputed and scrubbed. The component integrates 240 samples per second of duration up-front whenever stiffness, damping, mass, initialX, initialV, or duration changes. currentTime is a cursor into that array; autoplay and the slider both shift the cursor without rerunning the math, so identical inputs always yield identical waveforms — safe across SSR and hydration.
  3. Bob + waveform read the same value. The top half draws the spring + bob at the current x(t); the bottom half plots the full waveform with a cursor at the same t. As you scrub, both updates stay locked — the bob is just a vertical slice of the waveform.
  4. Damping regimes named in the readout. The component computes c_crit = 2·sqrt(k·m) and the damping ratio ζ = c / c_crit, then labels the regime as undamped, underdamped, critically damped, or overdamped. Drag the damping slider and watch the regime label flip — the same boundary that separates "overshoot then settle" from "creep to rest".
  5. Real-time autoplay via requestAnimationFrame. Unlike step-driven vizes, this one ticks in seconds. The RAF loop reads the wall-clock delta, scales by playSpeed, advances currentTime, and stops at duration.
  6. Reduced-motion fallback. With prefers-reduced-motion: reduce the bob and cursor snap directly to their new positions and autoplay is suppressed; the waveform path itself never animates.

Props

PropTypeDefaultDescription
stiffnessnumberControlled spring constant k. Pair with onStiffnessChange.
defaultStiffnessnumber100Uncontrolled initial stiffness.
onStiffnessChange(stiffness) => voidFires when the stiffness slider changes.
dampingnumberControlled damping coefficient c. Pair with onDampingChange.
defaultDampingnumber10Uncontrolled initial damping.
onDampingChange(damping) => voidFires when the damping slider changes.
massnumber1Mass of the bob m (kg).
currentTimenumberControlled time cursor (seconds). Pair with onCurrentTimeChange.
defaultCurrentTimenumber0Uncontrolled initial time.
onCurrentTimeChange(time) => voidFires on autoplay tick and manual scrub.
playingbooleanfalseWhen true, advances currentTime in real time.
playSpeednumber1Playback rate multiplier. 1 = real time.
initialXnumber1Initial displacement at t = 0.
initialVnumber0Initial velocity at t = 0.
durationnumber4Total simulated duration (seconds).
transitionTransitionSPRINGS.smoothSpring used for the bob + cursor visual smoothing.
classNamestringMerged onto the root via cn().

Accessibility

  • The figure is role="figure" with a labelled title and an aria-live="polite" summary that names the stiffness, damping, mass, damping ratio, regime, and the current (t, x, v) — every value the SVG shows is also exposed textually.
  • The three sliders are native <input type="range"> instances wrapped in LabeledSlider, so keyboard arrows nudge the value and screen readers narrate the value via aria-valuetext.
  • Color is never the only signal — the bob, cursor, and waveform share the same accent hue but the textual readouts below the canvas repeat every quantity.
  • prefers-reduced-motion: reduce snaps the bob and cursor directly to their target positions and suppresses autoplay; the waveform polyline itself does not animate.

Credits

  • Extracted from: craftingattention (app/src/lessons/primitives/viz/SpringViz.tsx). Generalised from a fixed (prediction, truth) squared-error spring scene into a configurable spring-mass-damper primitive: stiffness, damping, and mass as inputs; precomputed trajectory and time cursor as the playback model; controlled / uncontrolled pairs for stiffness, damping, and currentTime; real-time autoplay via requestAnimationFrame with a playSpeed multiplier.