CreaTech
LAB-008CSS onlyReactAccessible

Spring animation in CSS with the linear() easing function

A drawer that overshoots and settles like a real spring, with no physics library and no JavaScript animation loop.

Built 18 Aug 2026 · Last verified 18 Aug 2026

The short version

Cubic beziers cannot overshoot: they are bounded by their control points, so no bezier can describe a spring. linear() can, because it takes a list of points and interpolates straight lines between them, and those points are free to go past 1. You simulate the spring once, sample it into twenty-odd stops, and paste the result into a stylesheet as an ordinary easing function.

LAB-008live

How it works

  1. A spring is a damped harmonic oscillator. Given stiffness, damping and mass you get a closed-form position at any time t, so no step-by-step simulation is needed: it is one equation.
  2. This one uses stiffness 260, damping 24 and mass 1, which lands at a damping ratio of 0.74. Under 1 the spring overshoots, at 1 it stops exactly, above 1 it crawls in slowly.
  3. The settle time comes out of the same equation: keep sampling until the value stays within a fraction of its target and does not leave again. Here that is 477 milliseconds, with a 3 percent overshoot.
  4. Sample the curve at 23 evenly spaced points and you have the linear() arguments. More stops track the curve more closely; past about 30 the difference stops being visible and you are just making the stylesheet longer.
  5. The result is a plain easing value. It goes anywhere a cubic-bezier goes, it is interruptible like any CSS transition, and it runs on the compositor rather than the main thread.
  6. The maths runs once, at authoring time. What ships to the browser is a list of numbers, so there is no runtime cost and no library.

Why a bezier can never bounce

Every easing function CSS shipped with is a cubic bezier, and a cubic bezier cannot overshoot. Its curve is bounded by its control points, so the value it produces can approach 1 quickly or slowly, but it cannot pass 1 and come back. No amount of tuning cubic-bezier() will give you a spring, because the shape you want is not in the family of shapes it can draw.

That is why springs have meant a library. Framer Motion, React Spring and the rest run a physics simulation on the main thread, stepping the value every frame and writing it to the element.

linear() removes the reason to. It takes a list of points and draws straight lines between them, and those points are under no obligation to stay below 1. Give it enough of them and the straight lines are indistinguishable from a curve, including the part where the curve goes past its target and settles back.

The spring is one equation, not a simulation

You do not need to step through anything. A damped harmonic oscillator has a closed-form solution, so the position at any time t is a single expression:

const w0 = Math.sqrt(stiffness / mass);
const zeta = damping / (2 * Math.sqrt(stiffness * mass));
const wd = w0 * Math.sqrt(1 - zeta * zeta);
 
const pos = (t) =>
  1 - Math.exp(-zeta * w0 * t) * (Math.cos(wd * t) + (zeta * w0 / wd) * Math.sin(wd * t));

The number that matters is zeta, the damping ratio. Below 1 the spring overshoots and oscillates. At exactly 1 it arrives as fast as possible without overshooting. Above 1 it crawls in slowly and feels dead.

This drawer uses stiffness 260, damping 24, mass 1, which is a zeta of 0.74: one clear overshoot of about 3 percent, then settled. Softer than that and a drawer starts to feel like jelly. I tried 0.67, which overshoots 5.6 percent and takes 652ms, and it was noticeably too much for a panel this size.

Where the duration comes from

You do not choose the duration. The spring has one, and using a different number means the animation is cut off mid-motion or sits still at the end.

Find it by sampling forward until the value stays inside a tolerance and does not leave again:

let dur = 0;
for (let t = 0; t < 5; t += 0.001) {
  let settled = true;
  for (let u = t; u < t + 0.25; u += 0.01) {
    if (Math.abs(pos(u) - 1) > 0.002) { settled = false; break; }
  }
  if (settled) { dur = t; break; }
}

The inner loop is the part worth keeping. A spring crosses its target several times, so checking a single instant will report it settled at the first crossing, while it still has visible travel left. You have to check that it stays settled.

For these parameters: 477 milliseconds.

Sampling into stops

Take 23 evenly spaced samples and you have the arguments:

transition: translate 477ms linear(
  0, 0.0513, 0.1712, 0.3207, 0.4736, 0.6141, 0.734, 0.8304, 0.9037, 0.9563,
  0.9917, 1.0135, 1.0252, 1.0298, 1.0297, 1.0268, 1.0226, 1.018, 1.0135,
  1.0096, 1.0063, 1.0038, 1.002
);

The values above 1 are the overshoot. You can read the whole motion in the numbers: climb, pass the target at stop 12, peak at 1.0298, settle back.

More stops track the curve more closely. Past about 30 the difference stops being visible and you are only making the stylesheet longer. Fewer than about 15 and the straight lines start to show as a faint stepping in the overshoot, where the curve changes direction fastest.

What you get for it

The maths runs once, at authoring time. What ships is a list of numbers.

That means no library in the bundle, no physics loop on the main thread, and an animation that runs on the compositor like any other CSS transition. It is interruptible in the normal way: click the toggle mid-open and it reverses from where it is, no state to reconcile. And it inherits the platform's behaviour for free, including being disabled by the site's reduced-motion rule without the component knowing anything about it.

The trade is that the curve is fixed at build time. A spring library can respond to the velocity of a drag, starting the animation from however fast your finger was moving. linear() cannot: it is a static curve. For a drag-to-dismiss sheet, the library is still the right answer. For a drawer that opens when you press a button, which is almost every drawer, this is the whole feature at none of the cost.

The bit that is not the animation

Closed, the panel is off to the side but still in the layout, which means its contents are still focusable and still announced. A drawer whose links you can Tab into while it is invisible is a genuinely disorienting thing to encounter.

inert is the fix, and React takes it as a boolean:

<aside inert={!open}>

That removes the whole subtree from the tab order and the accessibility tree while leaving it painted and animatable. The alternative, visibility: hidden, would also work but cannot be transitioned smoothly without transition-behavior: allow-discrete, and inert says what is actually meant.

The source

MIT licensed. Use it in anything, no attribution needed.

Read straight off the file the demo above imports, then highlighted at build time. What you are reading is what is running.

Demo.tsxtsx
import { useState } from "react";
import styles from "./Demo.module.scss";

export default function SpringDrawer() {
  const [open, setOpen] = useState(false);

  return (
    <div className={styles.wrap}>
      <button
        type="button"
        className={styles.toggle}
        aria-expanded={open}
        aria-controls="lab-spring-panel"
        onClick={() => setOpen((o) => !o)}
      >
        {open ? "Close" : "Open"} the drawer
      </button>

      <div className={styles.stage}>
        <aside
          id="lab-spring-panel"
          className={`${styles.panel} ${open ? styles.open : ""}`}
          /* Closed, the panel is off to the side but still in the layout, so
             it has to leave the tab order and the accessibility tree too. */
          inert={!open}
          aria-label="Drawer"
        >
          <p className={styles.title}>It overshoots</p>
          <p className={styles.body}>
            Watch the edge pass its resting position and settle back. That is
            the spring, written as 23 stops of a linear() curve.
          </p>
        </aside>
      </div>
    </div>
  );
}
Demo.module.scssscss
/* A damped harmonic oscillator, sampled into easing stops.
   stiffness 260, damping 24, mass 1: 477ms, 3% overshoot, zeta 0.74.
   Generated once and pasted; the maths does not need to ship to the browser. */
$spring: linear(
  0, 0.0513, 0.1712, 0.3207, 0.4736, 0.6141, 0.734, 0.8304, 0.9037, 0.9563,
  0.9917, 1.0135, 1.0252, 1.0298, 1.0297, 1.0268, 1.0226, 1.018, 1.0135,
  1.0096, 1.0063, 1.0038, 1.002
);

.wrap {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 16px;
  width: 100%;
  padding: clamp(18px, 4vw, 30px);
}

.toggle {
  cursor: pointer;
  font-family: var(--sans);
  font-size: 14px;
  font-weight: 600;
  letter-spacing: -0.01em;
  padding: 10px 20px;
  border-radius: var(--r-pill);
  border: none;
  background: var(--aluminium);
  color: #15151a;
  box-shadow: var(--shadow-1);
  transition: filter 0.16s ease;

  &:hover {
    filter: brightness(1.06);
  }
}

/* Clips the panel while it is parked off to the right. */
.stage {
  position: relative;
  overflow: hidden;
  width: 100%;
  max-width: 420px;
  height: 148px;
  border-radius: var(--r-md);
  border: 1px solid var(--line-soft);
  background: var(--ink-900);
}

.panel {
  position: absolute;
  inset: 0 0 0 auto;
  width: 78%;
  padding: 18px;
  border-left: 1px solid var(--accent);
  background: var(--glass-strong);
  backdrop-filter: blur(12px);

  translate: 100% 0;
  /* The overshoot is in the curve, not in the duration. */
  transition: translate 477ms $spring;
}

.open {
  translate: 0 0;
}

.title {
  font-size: 15px;
  font-weight: 600;
  letter-spacing: -0.01em;
  color: var(--accent);
}

.body {
  margin-top: 6px;
  font-size: 13px;
  line-height: 1.6;
  color: var(--silver-300);
}

@container stage (max-width: 380px) {
  .stage {
    height: 128px;
  }
  .panel {
    width: 86%;
    padding: 14px;
  }
}

@media (prefers-reduced-motion: reduce) {
  /* A spring is overshoot by definition, so this one cuts rather than
     shortens: the drawer simply appears where it belongs. */
  .panel {
    transition: none;
  }
}

Questions worth answering

Does it work in Safari?

Chrome and Edge 113+, Safari 17.2+, Firefox 112+. Safe everywhere current. Older browsers ignore the linear() value and fall back to the default easing, so the drawer still opens; it just stops bouncing.

Does it need JavaScript?

Only to toggle the open state, which is a button, not an animation. The motion itself is one CSS transition. Compare that with a spring library, which runs a physics loop on the main thread for the length of every animation.

Is it accessible?

Reduced motion. The transition is removed rather than shortened, and the drawer appears where it belongs. A spring is overshoot by definition, so there is no gentler version of it to fall back to.

Keyboard. The toggle is a button carrying aria-expanded and aria-controls. While the drawer is closed it sits off to the side but stays in the layout, so it is marked inert: without that its links would still be reachable by Tab and announced by a screen reader while invisible.

Want this kind of care on a build?

I build fast, production-grade sites for freelance clients. The polish is the same; there is just more of it.