Lab WIP 3 min read

Spring Physics in Pure CSS

How far can you push linear() easing before you actually need Framer Motion? Generating spring curves from a physics equation, no JS at runtime.

CSSAnimationMotion

The --motion-spring token in my system uses cubic-bezier(0.34, 1.56, 0.64, 1). It overshoots slightly, it reads as physical, it works. But a cubic bézier can only approximate a spring — it’s four control points describing a cubic curve. A real spring has mass, stiffness, and damping, and the resulting motion is not a cubic curve. The question I wanted to answer: how much of that difference is actually perceptible, and can linear() close the gap?

linear() lets you pass arbitrary keyframe values along a timeline, so you can plot a real spring equation and hand the result directly to CSS. No JS at runtime — the values are hardcoded into the easing function.

The spring equation

A damped harmonic oscillator. The position at time t:

x(t) = 1 - e^(-ζωt) * (cos(ωdt) + (ζ/√(1-ζ²)) * sin(ωdt))

Where:

  • ω (omega) is the natural frequency, derived from stiffness and mass: √(stiffness / mass)
  • ζ (zeta) is the damping ratio: damping / (2 * √(stiffness * mass))
  • ωd is the damped frequency: ω * √(1 - ζ²)

For an underdamped spring (ζ < 1), which is the one that overshoots, this produces a decaying oscillation that settles at 1. That’s exactly what you want for an enter animation.

The generator — runs once at build time or in a script, never in the browser:

function springToLinear({ stiffness = 180, damping = 12, mass = 1, samples = 60 } = {}) {
  const omega = Math.sqrt(stiffness / mass)
  const zeta  = damping / (2 * Math.sqrt(stiffness * mass))
  const omegaD = omega * Math.sqrt(1 - zeta ** 2)

  const points = []
  const duration = 1000 // ms, used to sample evenly
  const step = duration / samples

  for (let i = 0; i <= samples; i++) {
    const t = (i * step) / 1000
    const x = 1 - Math.exp(-zeta * omega * t) * (
      Math.cos(omegaD * t) +
      (zeta / Math.sqrt(1 - zeta ** 2)) * Math.sin(omegaD * t)
    )
    points.push(x.toFixed(4))
  }

  return `linear(${points.join(', ')})`
}

Run it with stiffness: 180, damping: 12 and you get a linear() value with 61 points that traces the actual spring curve. Paste it into CSS as a custom property.

What it looks like

The generated value for stiffness: 180, damping: 12, mass: 1:

:root {
  --ease-spring-physics: linear(
    0, 0.0232, 0.0875, 0.1801, 0.2823, 0.3816, 0.4706, 0.5452,
    0.6035, 0.6453, 0.6717, 0.6843, 0.6852, 0.6765, 0.6604, 0.6389,
    0.6138, 0.5867, 0.5589, 0.5316, 0.5058, 0.4823, 0.4618, 0.4447,
    0.4312, 0.4212, 0.4148, 0.4115, 0.4111, 0.4132, 0.4172, 0.4228,
    0.4294, 0.4366, 0.4439, 0.4510, 0.4576, 0.4634, 0.4683, 0.4722,
    0.4751, 0.4771, 0.4783, 0.4788, 0.4787, 0.4783, 0.4776, 0.4767,
    0.4757, 0.4748, 0.4739, 0.4731, 0.4725, 0.4721, 0.4718, 0.4717,
    0.4717, 0.4719, 0.4721, 0.4723, 0.4725
  );
}
cubic-bezier spring vs physics spring — same duration
cubic-bezier(0.34, 1.56, 0.64, 1)
linear() — spring equation
/* Approximation */
transition: transform 600ms cubic-bezier(0.34, 1.56, 0.64, 1);

/* Physics */
transition: transform 600ms linear(
  0, 0.0232, 0.0875, 0.1801, 0.2823, 0.3816, 0.4706, 0.5452,
  0.6035, 0.6453, 0.6717, 0.6843, 0.6852, 0.6765, 0.6604, 0.6389
  /* ... 45 more points */
);

The overshoot shapes are different. The cubic bézier overshoots once and snaps back. The physics version oscillates — it’s a real decaying spring, and at higher stiffness values you can see multiple bounces before it settles.

What I haven’t figured out yet

Duration. A real spring doesn’t have a fixed duration — it settles when the oscillation falls below a threshold. To put it in linear(), you have to pick an arbitrary endpoint. I’m sampling at 1000ms, which is too long for most UI animations. You need to detect the “settled” point — when |x(t) - 1| < 0.001 — and use that as the sample window. Haven’t built the auto-detect yet.

Velocity matching. If you interrupt a spring mid-animation and start a new one, CSS has no way to preserve velocity. Framer Motion does this — it reads the current velocity and initialises the new spring from that state. With hardcoded linear() values, you get a snap at the interruption point. For drag interactions or gesture-driven animation this matters a lot. For enter/exit it mostly doesn’t.

The token integration. I want to replace --ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1) with the generated value. The problem is that linear() strings are long and ugly in a token file. Haven’t landed on the right abstraction — generate at build time and inject as a CSS variable, or accept the verbosity.

What it’s actually good for

Hover states and enter animations where you want the motion to feel grounded but you don’t need interruption handling. A card scaling in on hover, a tooltip appearing, a chip bouncing into place. The physics curve reads more natural than the cubic approximation in side-by-side testing — but in isolation I’m not sure most people notice.

For anything interaction-driven (drag, gesture, scroll-linked), you still need Framer Motion or the Web Animations API with velocity tracking. That’s the actual boundary.