---
name: spring-animation
description: Use when you want "Natural, polished, app-like" - Adds physics-based movement with bounce and settling.
---

# Spring Animation

> **Category:** Motion / Interaction  -  **Personality:** Natural, polished, app-like
>
> **Best use:** Menus, modals, draggable UI
>
> **Ratings:** Professional 5/5 - Casual 4/5 - Exotic 3/5 - Visual impact 4/5 - Difficulty 3/5 - Performance cost 2/5

## What it is

Spring animation moves an element the way a real spring would: instead of a fixed-duration CSS easing curve, a tiny physics loop integrates a restoring force (`-k·x`) plus damping (`-c·v`) every frame, so motion **overshoots its target and settles**. Because it carries velocity, it reacts naturally to interruption — fling a draggable card and it springs back home carrying your throw's momentum. This is what gives iOS / Material / Framer-Motion interfaces their "alive" feel, which is why it's the default for draggable cards, sheets, menus and toggles. It is distinct from a CSS-only "elastic" hover: here the bounce is genuine JS physics and is usually drag-driven.

## Dependencies / CDN

**None - vanilla JS.** No animation library is needed; the integrator is ~6 lines. The demo only loads display fonts (optional):

```html
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Fredoka:wght@500;600&family=Hanken+Grotesk:wght@400;500&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
```

## HTML

A draggable card living on a positioned "board". The same `Spring` class also drives the demo's toggle knob and bottom-sheet — point it at any numeric property.

```html
<div class="board" id="board">
  <svg class="coil" id="coil" aria-hidden="true"><path id="coilPath"/></svg>  <!-- optional spring tether -->
  <div class="home" aria-hidden="true"></div>                                  <!-- rest target marker -->
  <div class="card" id="card" role="button" tabindex="0"
       aria-label="Drag and release to spring back, or flick with arrow keys">
    Cassette Sun — June Halliday
  </div>
</div>
```

## CSS

```css
.board{position:relative; overflow:hidden; border-radius:18px; min-height:336px; background:#0c0b13}
.card{
  position:absolute; left:50%; top:50%;
  transform:translate(-50%,-50%);     /* JS rewrites the whole transform incl. this centring */
  will-change:transform;              /* promote to its own layer */
  touch-action:none;                  /* stop the browser scrolling while you drag on touch */
  -webkit-user-select:none; user-select:none; cursor:grab;
}
.card.is-grabbed{cursor:grabbing; box-shadow:0 44px 84px -30px rgba(0,0,0,.95)}
.card:focus-visible{outline:2px solid #caf24c; outline-offset:3px}
.coil path{fill:none; stroke:#caf24c; stroke-width:3; stroke-linecap:round}
```

## JavaScript

The whole effect is the `Spring` class plus one rAF loop. Drag sets the target to the pointer (rubber-band); release sets the target home and keeps the velocity, so it flings and settles.

```js
/* semi-implicit (symplectic) Euler — stable & cheap. mass = 1.
   force = -k(x-target) - c·v   (Hooke restoring + viscous damping) */
function Spring(k, d, eps){ this.k=k; this.d=d; this.value=0; this.target=0; this.vel=0; this.eps=eps; }
Spring.prototype.step = function(h){
  var force = -this.k*(this.value - this.target) - this.d*this.vel;
  this.vel  += force * h;          // a = force / m
  this.value += this.vel * h;
};
Spring.prototype.rest = function(){
  return Math.abs(this.value - this.target) < this.eps && Math.abs(this.vel) < this.eps;
};

var sx = new Spring(170, 18, 0.12), sy = new Spring(170, 18, 0.12);   // k = stiffness, d = damping
var reduce = matchMedia('(prefers-reduced-motion: reduce)').matches;
var dragging = false, gx = 0, gy = 0;

/* ---- fixed-substep loop: variable rAF dt with a stiff spring can explode, so we
        integrate at a fixed 1/120 s and only run while something is actually moving ---- */
var raf = 0, last = 0, acc = 0, FIXED = 1/120;
function loop(now){
  if(!last) last = now;
  var dt = (now - last)/1000; last = now; if(dt > 0.05) dt = 0.05;   // clamp tab-switch spikes
  acc += dt;
  while(acc >= FIXED){ sx.step(FIXED); sy.step(FIXED); acc -= FIXED; }
  render();
  if(dragging || !sx.rest() || !sy.rest()) raf = requestAnimationFrame(loop);
  else { raf = 0; last = 0; acc = 0; }                              // sleep when settled
}
function wake(){
  if(reduce){ sx.value=sx.target; sy.value=sy.target; sx.vel=sy.vel=0; render(); return; } // no bounce
  if(!raf){ last = 0; acc = 0; raf = requestAnimationFrame(loop); }
}
function render(){
  card.style.transform = 'translate(-50%,-50%) translate('+sx.value.toFixed(2)+'px,'+sy.value.toFixed(2)+'px)'
    + ' rotate('+Math.max(-13,Math.min(13, sx.vel*0.016)).toFixed(2)+'deg)';   // tilt into the throw
}

/* ---- drag = throw, release = spring home ---- */
card.addEventListener('pointerdown', function(e){
  dragging = true; card.setPointerCapture(e.pointerId); card.classList.add('is-grabbed');
  var r = board.getBoundingClientRect();
  gx = sx.value - (e.clientX - r.left - r.width/2);     // grab offset = pick up where you click
  gy = sy.value - (e.clientY - r.top  - r.height/2);
  e.preventDefault(); wake();
});
window.addEventListener('pointermove', function(e){
  if(!dragging) return; e.preventDefault();
  var r = board.getBoundingClientRect();
  sx.target = (e.clientX - r.left - r.width/2) + gx;    // spring chases the pointer (rubber-band)
  sy.target = (e.clientY - r.top  - r.height/2) + gy;
  reduce ? (sx.value=sx.target, sy.value=sy.target, render()) : wake();
});
window.addEventListener('pointerup', function(e){
  if(!dragging) return; dragging = false; card.classList.remove('is-grabbed');
  sx.target = 0; sy.target = 0;                         // home; KEEP velocity -> it flings then settles
  var cap = 2600; sx.vel = Math.max(-cap,Math.min(cap,sx.vel)); sy.vel = Math.max(-cap,Math.min(cap,sy.vel));
  wake();
});
```

## How it works

- **The force equation is the whole effect.** Each frame, `force = -k·(value-target) - c·vel`. The `-k·x` term always pulls toward the target; the `-c·v` term bleeds energy. Integrate force → velocity → position with semi-implicit Euler (update `vel` *first*, then `value`) and you get natural acceleration, overshoot and settle for free.
- **Stiffness `k`** = how hard it's pulled (higher = faster, snappier). **Damping `c`** = how much it resists motion (higher = less bounce). Underdamped `c < 2√k` overshoots and oscillates; critical `c = 2√k` is the fastest no-bounce return; overdamped is slow and sluggish.
- **Drag feeds the spring.** While dragging, the target tracks the pointer, so the spring builds real velocity. On release the target jumps to home but the velocity is *kept* — so a hard throw overshoots far, a gentle one barely bounces. That momentum-carry is what fixed CSS transitions can't do.
- **Fixed substeps = stability.** A stiff spring stepped with a long/irregular `requestAnimationFrame` delta diverges and explodes. Accumulate real time and integrate in fixed `1/120 s` chunks; clamp the delta so a backgrounded tab doesn't dump a huge `dt`.
- **Sleep when settled.** When `|value-target|` and `|vel|` fall under an epsilon, snap to target and stop the loop — zero CPU at rest.

## Customization

- **Feel presets** (the demo's chips): Gentle `k130 c22` (no overshoot), Smooth `k170 c18` (subtle bounce, a good default), Bouncy `k260 c10` (playful overshoot), Stiff `k380 c22` (fast & snappy).
- **Drive anything numeric:** the demo reuses the *same* class for a toggle knob (`0→1`, e.g. `k620 c15` for a tight overshoot) and a bottom-sheet's `translateY` (`k220 c21`). Reading a CSS variable instead of a transform works too.
- **Extra springs for richness:** a separate scale spring (`1 → 1.06` on grab) gives a "pick-up" pop; derive rotation from `vel` to tilt into the throw; draw an SVG zig-zag tether between anchor and card whose amplitude shrinks as it stretches.
- **Mass:** add a `mass` field and divide force by it for heavier/lazier objects.

## Accessibility & performance

- **`prefers-reduced-motion`:** check it with `matchMedia` and, when set, jump straight to the target (no oscillation, no loop). Dragging itself is direct manipulation and stays — only the *bounce* is removed.
- **Keyboard:** make the draggable element `tabindex="0"` + `role="button"`; map arrow keys to a velocity impulse (`sx.vel += 1200`) so the spring is reachable without a pointer, and give it a visible `:focus-visible` ring.
- **Cheap:** only `transform`/`opacity` are animated (GPU-composited, no layout/paint); the loop sleeps when at rest. `will-change:transform` keeps the card on its own layer.
- **Touch:** set `touch-action:none` on the draggable element or the browser will scroll instead of letting you drag; use Pointer Events + `setPointerCapture` so a fast drag that leaves the element still tracks.

## Gotchas

- **Update velocity before position.** Plain (explicit) Euler — moving position first — adds energy and can oscillate forever or blow up. Semi-implicit Euler (vel then value) is stable and is the one above.
- **Never step with the raw rAF delta on a stiff spring.** One long frame (alt-tab, GC pause) and `value` rockets to `NaN`. Clamp `dt` and integrate in fixed substeps.
- **JS owns the *entire* transform string**, including the `translate(-50%,-50%)` centring — if you also set transform in CSS it gets overwritten each frame. Centre via `left/top:50%` + the JS translate.
- **Cap the release velocity** or a hard fling overshoots clean off the stage. Clamping `vel` on release (and clamping the drag target to the board) keeps it contained.
- **Don't forget to stop the loop.** A `requestAnimationFrame` that never checks `rest()` burns battery forever; gate continuation on "still dragging OR not at rest".
- **Tune `eps` per unit.** A pixel spring rests fine at `0.12`; a `0..1` spring (toggle/sheet) needs a far smaller epsilon (~`0.0009`) or it stops visibly short.
