EmpireUI
Get Pro
← Blog9 min read#react-spring#physics#spring

react-spring Physics Guide: Springs, Trails, Parallax in React

Master react-spring's physics engine — springs, mass, tension, trails, and parallax — with real code examples that feel alive, not mechanical.

abstract spring physics motion blur visualization on dark background

Why react-spring Exists (and Why You Should Care)

CSS transitions are great until they're not. The moment you need something to feel like it has weight — like it's being pulled, released, or caught mid-air — you hit the ceiling of what transition: all 0.3s ease can do. That's exactly the gap react-spring fills.

react-spring, first released in 2018, doesn't think in time. It thinks in physics. Instead of asking 'how long should this animation take?', you ask 'how stiff is this spring, how heavy is the mass, how much friction is there?' The engine figures out the duration itself based on those constraints. Sounds weird at first. Feels completely natural once you get it.

Honestly, the mental model shift is the hardest part. Once you stop thinking in milliseconds and start thinking in tension/friction/mass, you'll produce animations that feel alive rather than mechanical. That's the entire point.

Worth noting: react-spring v9 (the current major as of 2026) is a ground-up rewrite with hooks-first API. If you're reading old tutorials that show <Spring> render-props components everywhere, they're targeting v8. The examples here all use v9's hook API — useSpring, useTrail, useParallax.

The Core API: useSpring and animated.*

Let's start where everyone should — useSpring. It's the bread and butter, the primitive everything else builds on. You give it a config object describing where values should go, and it gives you back animated values you wire directly into JSX via animated.* components. ``jsx import { useSpring, animated } from '@react-spring/web' function FadeCard() { const [visible, setVisible] = useState(false) const styles = useSpring({ opacity: visible ? 1 : 0, transform: visible ? 'translateY(0px)' : 'translateY(24px)', config: { tension: 280, friction: 60 }, }) return ( <animated.div style={styles}> <button onClick={() => setVisible(v => !v)}>Toggle</button> </animated.div> ) } ``

The config object is where the physics live. tension controls how aggressively the spring pulls toward its target — higher values mean snappier movement. friction is the drag that slows it down. Too little friction and you get that satisfying bounce. Too much and it barely overshoots. You'll tune these constantly, and that's fine.

Quick aside: animated.div, animated.span, animated.img — these are special wrapped versions of HTML elements that know how to consume react-spring's interpolated values without triggering React re-renders on every frame. This is a big deal for performance. Regular div with react-spring values won't animate smoothly; always use animated.*.

In practice, the preset configs (config.stiff, config.wobbly, config.gentle, config.molasses) get you 80% of the way there without manual tuning. config.wobbly with default tension 180 and friction 12 gives you that elastic, modern feel that's everywhere in 2024-era design trends. Start there, dial in from there.

One more thing — useSpring also accepts an imperative API via a ref or a returned set function, which is useful when you need to trigger animations outside of React's render cycle. We'll come back to this in the parallax section.

Configuring the Physics: Tension, Mass, Friction Demystified

Three numbers control everything. Let's be precise about what each one does, because vague descriptions like 'bounciness' will only take you so far. tension (default: 170) — Think of this as the spring constant in Hooke's Law. High tension (300+) means the spring pulls hard and fast toward the target. Low tension (80–120) means it drifts lazily. For UI elements like modals and dropdowns, you typically want 200–320. friction (default: 26) — This is your damping coefficient. High friction means the spring overshoots less (or not at all). Set friction below 10 and you'll get a very bouncy, underdamped spring. Friction around 60–80 gives you critically damped behavior — it reaches the target without any oscillation. For professional UIs, 40–60 is a sweet spot. mass (default: 1) — The inertia of the animated element. Higher mass means it accelerates slower and decelerates slower. Bumping mass to 2–3 can make large UI panels feel appropriately heavy without making them slow. Combine high mass with high tension to get a spring that's slow to start but snappy at the end. ``js // A deliberate, heavy-feeling slide-in panel const config = { tension: 250, friction: 80, mass: 3 } // A quick, snappy tooltip const config = { tension: 400, friction: 30, mass: 0.8 } // A playful, bouncy badge const config = { tension: 180, friction: 8, mass: 1 } ``

Look, most tutorials skip mass entirely and wonder why their animations feel samey. Mass is the variable that gives you differentiation between lightweight UI micro-interactions and big-component entrances. Don't skip it.

There's also clamp: true if you need the animation to stop at the target value without any overshoot — useful for progress bars or numeric counters where overshooting past 100% would look broken. Not everything needs to bounce.

useTrail: Staggered Lists That Feel Real

If you've ever done a staggered list animation in Framer Motion or GSAP, you know the pattern: animate items in sequence with a delay offset. react-spring's useTrail does the same thing, but with a twist — each element in the trail is physically connected to the one before it. The second item drags behind the first, the third drags behind the second. It looks incredible. ``jsx import { useTrail, animated } from '@react-spring/web' const items = ['Dashboard', 'Analytics', 'Settings', 'Profile'] function NavMenu({ open }) { const trail = useTrail(items.length, { opacity: open ? 1 : 0, x: open ? 0 : -20, config: { tension: 220, friction: 30 }, delay: open ? 100 : 0, }) return ( <nav> {trail.map((style, i) => ( <animated.div key={items[i]} style={style}> {items[i]} </animated.div> ))} </nav> ) } ``

The delay on open gives the container a moment to appear before the items cascade in. The reverse — setting delay: 0 when open is false — means the exit animation collapses immediately, which feels intentional rather than laggy. Little detail, big difference.

That said, useTrail is a blunt instrument for very long lists (think 50+ items). Each item adding its own physics chain means the last item might still be animating when the user has already scrolled past. For those cases, keep your trail to 8–12 items max, or use GSAP's stagger which gives you more granular timing control per item.

Trails work brilliantly for navigation menus, card grids on load, and feature lists in landing pages. If you're building the kind of polished UI that Empire UI's components deliver, trails are a go-to pattern for hero section content reveals. The physics-based cascade immediately signals quality.

useParallax: Depth Without the Boilerplate

Parallax effects — where elements move at different speeds as you scroll — used to require IntersectionObserver setups, scroll event listeners, and a fair amount of math. react-spring's useParallax hook, available from @react-spring/parallax, wraps all of that into a clean, declarative API. ``jsx import { ParallaxLayer, Parallax } from '@react-spring/parallax' export function HeroSection() { return ( <Parallax pages={3} ref={parallaxRef}> <ParallaxLayer offset={0} speed={0.5}> <BackgroundImage /> </ParallaxLayer> <ParallaxLayer offset={0} speed={1}> <HeroText /> </ParallaxLayer> <ParallaxLayer offset={1} speed={0.2}> <FeatureGrid /> </ParallaxLayer> </Parallax> ) } ``

The speed prop controls how fast each layer scrolls relative to the viewport. speed={0} pins the layer in place; speed={1} is normal scroll speed; speed={2} scrolls twice as fast. For a classic depth effect, put your background at speed={0.3} and foreground content at speed={1}. The gap between those two numbers creates the illusion of depth.

Worth noting: <Parallax> takes over the scroll container, so it needs a fixed height (pages * 100vh worth of scroll). This means it won't play nice with your regular page scroll unless you isolate it inside a section with overflow: hidden. Plan your layout before reaching for this — it's powerful but opinionated. For lighter touch parallax on scroll events without the container takeover, useSpring with a useScroll hook from @react-spring/core gives you more flexibility.

One more thing — if you're building parallax sections as part of a visually styled page, pair this with a style system that already handles depth cues. Something like glassmorphism components plays perfectly with parallax because the frosted-glass layers visually reinforce the depth the parallax is creating in motion.

For smooth 60fps parallax, keep your layer count under 8. Each ParallaxLayer needs its own compositor layer, and browsers start dropping frames fast once you stack 10+ transform-heavy elements on a single scroll container.

Imperative Control with useSpringRef and useChain

Sometimes you don't want React state to drive your animations. A drag-to-dismiss gesture, a sequence triggered by a WebSocket event, a multi-step animation where one must fully complete before the next begins — these need imperative handles. ``jsx import { useSpring, useSpringRef, useChain, animated } from '@react-spring/web' function ExpandCard() { const expandRef = useSpringRef() const contentRef = useSpringRef() const expand = useSpring({ ref: expandRef, from: { width: '200px', height: '60px' }, to: { width: '400px', height: '300px' }, config: { tension: 300, friction: 40 }, }) const content = useSpring({ ref: contentRef, from: { opacity: 0 }, to: { opacity: 1 }, }) // expand first, then fade content in at 0.5 of expand duration useChain([expandRef, contentRef], [0, 0.5]) return ( <animated.div style={expand}> <animated.div style={content}> Card content here </animated.div> </animated.div> ) } ``

The second array in useChain is the timing offsets — [0, 0.5] means the second animation starts when the first is 50% complete. You can also pass a timeFrame third argument to set an absolute duration window. This is the cleanest way to orchestrate multi-step sequences in react-spring.

Honestly, useChain is underused. Most devs reach for setTimeout hacks or manual delay props when they need sequencing. useChain is the right tool — it stays in sync with the physics engine so the chain timing adjusts if the spring config changes. That's not something a hardcoded timeout can do.

The imperative springRef.start() and springRef.stop() methods are there when you need event-driven control. Combine them with pointer events for gesture-driven UIs — or check out @use-gesture/react, which is the official companion library for react-spring and handles drag, pinch, scroll, and wheel gestures with first-class spring integration.

Performance, Gotchas, and the Real-World Checklist

React-spring animates on the React fiber, but it tries hard to do updates outside the render cycle via direct DOM mutations. That said, there are still ways to tank performance if you're not careful. Here's what actually bites people in production.

Don't animate layout-triggering properties. width, height, padding — these force browser reflow on every frame. Instead, use transform: scaleX() and transform: scaleY(), then compensate with transform-origin. Similarly, animate opacity and transform first; only reach for other properties when you genuinely need them. The cubic-bezier guide has a good breakdown of which properties are paint-safe vs. layout-triggering. ``jsx // Bad — triggers layout const bad = useSpring({ width: open ? 400 : 0 }) // Good — GPU composited const good = useSpring({ transform: open ? 'scaleX(1)' : 'scaleX(0)' }) ``

Memoize your spring configs. If you're building a config object inline (e.g., config={{ tension: 220, friction: 30 }}), React creates a new object reference every render. react-spring is smart enough not to restart the animation for this, but it still adds unnecessary work. Define the config object outside the component or inside a useMemo. Watch your `immediate` prop. immediate: true skips the physics and jumps directly to the target value. This is essential for SSR hydration (you don't want animations playing on first load before the user expects them), but accidentally leaving it on destroys the whole point of using react-spring.

Quick aside: react-spring plays well with prefers-reduced-motion. Wrap your config in a hook that checks window.matchMedia('(prefers-reduced-motion: reduce)') and sets immediate: true when the user has opted out of motion. This is a WCAG 2.2 compliance issue, not just a nice-to-have. The react accessibility guide covers this pattern in depth.

When you're building polished UIs — the kind that draw from design systems with strong visual identity — react-spring's physics can amplify or undercut the style depending on your config choices. A glassmorphism generator gives you the visual layer; react-spring gives you the motion layer. Together they're what separates a portfolio project from something that feels genuinely finished.

FAQ

What's the difference between react-spring and Framer Motion?

react-spring uses physics-based springs (tension, friction, mass) — no durations. Framer Motion uses duration-based easing with some spring support bolted on. react-spring gives you more natural-feeling motion; Framer Motion has a better layout animation story and simpler API for beginners.

Can I use react-spring with TypeScript?

Yes, react-spring ships full TypeScript types in v9. The animated.* components and all hooks are typed. You might need to cast complex interpolated style objects with as React.CSSProperties occasionally, but day-to-day usage is clean.

Does react-spring work with React Native?

Yes. Import from @react-spring/native instead of @react-spring/web. The hook API is identical — same useSpring, same config objects. The only difference is you use RN's Animated.View-equivalent wrappers instead of animated.div.

How do I stop a spring animation mid-flight?

Call api.stop() on the controller returned by the imperative form of useSpring: const [styles, api] = useSpring(() => ({...})). Then api.stop() halts at the current interpolated value, and api.start() resumes from there.

Free components in 40 styles
React & Tailwind, copy-paste ready.
Browse →

Read next

Popmotion: The Animation Engine Behind Framer MotionCSS Parallax Without JavaScript: perspective, transform-style, Layersreact-spring: Physics-Based Animations Without the ComplexityParallax Scrolling in React: useScroll, GSAP and Pure CSS