Understanding Spring Animations in SwiftUI

Understanding Spring Animations in SwiftUI

Learn how to use spring animations to enhance the experience of your users in SwiftUI apps.

Have you ever noticed how some apps just feel right when interacting with them? A button that bounces when tapped, a component that slides into a place settling gently, a toggle that snaps with the right amount of weight. That subtle magic often comes from spring animations.

Animations in apps aren't just decorations, they're communication. They tell the user their actions are being acknowledged and that the interface is responding. Among all the animations in SwiftUI, springs stand out as the most powerful for creating interactions that feel natural and alive.

Springs feel natural because they mimic real physics. When you drop a ball, it doesn't just stop when it hits the ground, it bounces, smaller and smaller, until it settles. When you pull back a rubber band and release it, it snaps forward with momentum, overshoots slightly, then oscillates until it finds rest. This physics-based motion is what our brains recognize from a lifetime of interacting with physical objects.

Instead, something different happens when something like .easeInOut is used for interactive UI. This kind of animations are great for things that happens automatically (like the fading action of a loading screen), but for direct user interactions, these animations lack the physical touch that feels natural.

A spring animation simulates real physics. When a UI element is attached to an invisible spring:

  1. Changing its position (like making a view wider) causes the spring to pull it toward the target
  2. Momentum builds up, causing a slight overshoot
  3. The spring pulls back, creating a small bounce
  4. Damping gradually absorbs the energy until everything settles

SwiftUI handles the physics calculations, letting you focus on the feel you want, there's no need to understand the math behind it to use springs effectively.

Let's create a box that expands when tapped, by giving a look at the UI and the code.

0:00
/0:02
struct BounceAnimation: View {
    @State private var isExpanded = false
    
    var body: some View {
        RoundedRectangle(cornerRadius: 20)
            .frame(width: isExpanded ? 260 : 140,
                   height: isExpanded ? 180 : 140)
            .onTapGesture {
                withAnimation(.spring()) {
                    isExpanded.toggle()
                }
            }
    }
}

Tapping the box produces a bounce that makes the app feel responsive. Even though it's possible replacing .spring() with .easeInOut(duration: 0.5) to create the same kind of animation, it surely will feel more mechanical.

0:00
/0:02

The default .spring() is tuned for most interactive UI. But when you need more control, springs offer two main parameters:

  1. Response: it controls how fast the motion happens:
spring(response: 0.3)  // Quick and snappy, ideal for small UI feedback like button presses
.spring(response: 0.55) // Default, balanced
.spring(response: 0.9)  // Slower, more deliberate, works for dramatic transitions like modal presentations
0:00
/0:04
  1. Damping fraction: it controls how bouncy the animation is:
.spring(response: 0.55, dampingFraction: 0.5)  // More bounce
.spring(response: 0.55, dampingFraction: 0.75) // Balanced (default)
.spring(response: 0.55, dampingFraction: 0.95) // Subtle bounce
0:00
/0:03

Two Ways of Animating

SwiftUI offers two ways to trigger animations. Understanding the difference makes it easier to apply springs effectively.

The withAnimation Approach

Wrapping state changes causes everything that depends on those changes to animate:

Button("Toggle") {
    withAnimation(.spring()) {
        isExpanded.toggle()
    }
}

This works well when coordinating multiple things animating at once.

The .animation(_:value:) Modifier

Attaching an animation to a specific view makes it animate whenever that particular value changes:

Circle()
    .offset(x: isOn ? 28 : -28)
    .animation(.spring(), value: isOn)

The view animates automatically whenever isOn changes, so there's no need to wrap the state change.

Both approaches are valid, choosing one over the other is about intent and control.

Interactive Springs

There's a special spring modifier for gesture-driven animations: .interactiveSpring().

The distinction matters because gestures are continuous, so the user is actively moving something, and the target might change mid-animation. Interactive springs are optimized to blend smoothly when interrupted.

Let's see an example of the usage of this modifier, for instance a draggable card that snaps back when released.

0:00
/0:02
@State private var offset: CGFloat = 0

var body: some View {
RoundedRectangle(cornerRadius: 16)
    .frame(width: 200, height: 80)
    .offset(x: offset)
    .gesture(
        DragGesture()
            .onChanged { value in
                offset = value.translation.width
            }
            .onEnded { _ in
                withAnimation(.interactiveSpring()) {
                    offset = 0
                }
            }
    )
}

Dragging and releasing produces a satisfying snap-back with spring bounce.

Real-World Example: Custom Toggle

Let's see how these concepts come together. This toggle demonstrates three spring principles: parameter tuning (response: 0.35), targeted animation (only the circle bounces), and proper hit testing (.contentShape):

struct CustomToggle: View {
    @State private var isOn = false
    
    var body: some View {
        RoundedRectangle(cornerRadius: 30)
            .frame(width: 120, height: 60)
            .foregroundStyle(isOn ? .green : .gray)
            .contentShape(RoundedRectangle(cornerRadius: 30))
            .overlay(
                Circle()
                    .frame(width: 44, height: 44)
                    .foregroundStyle(.white)
                    .shadow(radius: 3)
                    .offset(x: isOn ? 28 : -28)
                    .animation(
                        .spring(response: 0.35, dampingFraction: 0.75),
                        value: isOn
                    )
            )
            .onTapGesture {
                isOn.toggle()
            }
    }
}

The animation is attached to the circle's state, not the tap gesture itself. This provides fine control over what animates and when.

The .contentShape() modifier ensures the entire rounded rectangle is tappable, a small detail that improves the interaction.

0:00
/0:02

When to Choose Springs

Springs excel when the user causes the motion, for instance taps, drags, and swipes. They work perfectly for buttons, toggles, sliders, drag gestures, and pull-to-refresh because they respond to physical manipulation.

They don't work well for things that happen independently of user interaction. Use .linear or .easeInOut when something is loading, progressing, or happening automatically.

For instance, adding springs to loading spinners or progress bars is a common mistake. These should be smooth and constant, not bouncy.

And that’s it! Spring animations aren’t about adding flair, they’re about making interfaces feel human. That small bounce is feedback, like a silent “I got it” from the UI. It’s the difference between an app that simply works and one that feels responsive.

Animations won’t fix poor design, but when used with intention, they elevate everything. So next time you animate something, ask yourself: is this responding to a human action?

If it is, a spring is probably the right choice.

Once you start noticing them, you’ll see springs everywhere, in the interactions that feel right, and in the apps you enjoy using the most.