How to Make UI Animation Interruptible in React
Why CSS transitions jump when a user changes their mind, how springs keep velocity across an interruption, and how to test that your React animation survives rapid input.
Core Takeaway
An animation is interruptible when a new user input redirects it from wherever it currently is, keeping its current velocity, instead of restarting from a fixed start value. Springs are interruptible because they describe a target and a physical state; duration-based transitions are not, because they replay a fixed curve from the start. In React, the fix is to drive the value with a spring and read the current value and velocity when the next input arrives.
What actually goes wrong
A CSS transition from A to B has no memory. If the user reverses direction halfway, the element snaps toward A from a curve that assumes it started at rest. The result is a visible jump in speed, and sometimes a visible jump in position if the property is being restarted by a class toggle. The user reads this as the interface ignoring them.
- Duration-based: fixed start, fixed end, fixed time. Interruption restarts the clock.
- Spring-based: a target plus current position and velocity. Interruption only changes the target.
- The tell is a speed change at the moment of input. Position rarely jumps in springs; it jumps constantly in restarted transitions.
The three inputs that get interrupted most
Interruption shows up in predictable places. Fixing these three covers most of the visible jank in a product.
- Drag then release: the element must continue with the throw velocity, not stop dead at the release point.
- Toggle tapped twice quickly: the second tap must reverse direction from the current position.
- Sheet or drawer dismissed mid-open: the close must start from the current offset, not from fully open.
The spring model in one paragraph
A spring is described by mass, stiffness and damping. Stiffness pulls toward the target, damping removes energy, mass resists both. When you change the target mid-flight, you keep the current position and velocity and let those three numbers do the rest. That is the whole trick, and it is why spring libraries feel better than transitions under fast input.
- Higher stiffness: faster response, more chance of overshoot.
- Higher damping: less overshoot, slower settle.
- Critically damped: no overshoot. Use it for menus and sheets where a bounce looks accidental.
- Underdamped: small overshoot. Use it for playful, low-stakes controls.
Implementing it in React
With Motion, use a spring transition and let the component re-render with a new target; Motion keeps the in-flight velocity. With React Spring, useSpring does the same through its animated values. With plain React state you cannot do this at all, because state changes are discrete: by the time you read the new value the old animation is already gone.
- Pass the target as state, never as a fixed duration.
- Do not remount the element on interrupt. Remounting throws away the velocity you need.
- Keep the spring configuration in one place so every interruptible element feels related.
When a state machine file is the better home
If the interruptible behaviour has to be identical on web and in native apps, or a designer needs to tune the spring without shipping code, the spring belongs in the file rather than the component. ExodeUI stores mass, stiffness and damping per animation, so the same curve plays in React, SwiftUI and Flutter, and React only changes the input that starts or reverses it.
- Keep the transition rule in the file, not in a React effect that fires on every render.
- Expose one input per direction rather than a single "open" boolean if reverse needs to keep velocity.
- Read input names from the file before wiring the React side.
How to test that it is actually interruptible
Manual clicking hides the problem because people rarely click faster than the animation. Test it deliberately.
- Click or drag twice within 100 ms and watch for a speed discontinuity at the second input.
- Record the interaction and step through frames; a restart shows as a velocity spike.
- In Playwright, drive two pointer events with a short gap and compare screenshots at fixed offsets.
- Check reduced motion: the element should jump directly to the target with no spring at all.
Production Implementation Snippet
// Motion: pass a target, not a duration. Velocity is preserved on interrupt.
import { motion } from 'motion/react';
export function Sheet({ open }) {
return (
<motion.div
animate={{ y: open ? 0 : 320 }}
transition={{ type: 'spring', stiffness: 420, damping: 38, mass: 0.9 }}
/>
);
}
// ExodeUI: the spring lives in the file; React only changes the input.
import { ExodeUICanvas } from 'exodeui';
<ExodeUICanvas
src="/sheet.bexode"
inputs={{ open }}
fit="Layout"
/>Frequently Asked Questions
Sources
- CSS transition (no velocity carried across a restart) — MDN Web Docs
- prefers-reduced-motion — MDN Web Docs
- WAI-ARIA Authoring Practices: dialog and sheet patterns — W3C WAI