```html SwiftUI: How to Spring Animation (iOS 17+, 2026)

How to implement spring animation in SwiftUI

iOS 17+ Xcode 16+ Beginner APIs: withAnimation / .spring Updated: May 11, 2026
TL;DR

Wrap any state change in withAnimation(.spring(…)) and SwiftUI automatically springs every animatable modifier that depends on that state. iOS 17 introduced a new Spring type with expressive parameters — duration, bounce, and blendDuration — replacing the old dampingFraction API.

import SwiftUI

struct TLDRView: View {
    @State private var isExpanded = false

    var body: some View {
        Circle()
            .fill(.indigo)
            .frame(width: isExpanded ? 200 : 80,
                   height: isExpanded ? 200 : 80)
            .onTapGesture {
                withAnimation(.spring(duration: 0.4, bounce: 0.35)) {
                    isExpanded.toggle()
                }
            }
    }
}

Full implementation

The example below demonstrates all three common spring targets at once: scale, offset, and opacity. A single withAnimation call drives all three simultaneously, so they stay in sync. The iOS 17 .spring(duration:bounce:) overload is used throughout — it replaces the old response/dampingFraction pair with more intuitive parameters. The preview includes a realistic toggle button so you can feel the animation in Xcode's canvas without running the simulator.

import SwiftUI

// MARK: - Model

struct SpringDemoCard: Identifiable {
    let id = UUID()
    let title: String
    let subtitle: String
    let color: Color
}

// MARK: - View

struct SpringAnimationView: View {
    @State private var isVisible = false
    @State private var selectedIndex: Int? = nil

    private let cards: [SpringDemoCard] = [
        .init(title: "Bounce",    subtitle: "High energy",  color: .indigo),
        .init(title: "Smooth",    subtitle: "No overshoot", color: .teal),
        .init(title: "Snappy",    subtitle: "Fast settle",  color: .orange),
    ]

    var body: some View {
        NavigationStack {
            ScrollView {
                VStack(spacing: 20) {
                    // Hero badge — scale + opacity spring
                    ZStack {
                        RoundedRectangle(cornerRadius: 20, style: .continuous)
                            .fill(.indigo.gradient)
                            .frame(height: 160)
                        Text("Spring Animation")
                            .font(.title.bold())
                            .foregroundStyle(.white)
                            .scaleEffect(isVisible ? 1.0 : 0.4)
                            .opacity(isVisible ? 1 : 0)
                    }
                    .padding(.horizontal)

                    // Cards — staggered offset spring
                    ForEach(Array(cards.enumerated()), id: \.element.id) { index, card in
                        CardRow(card: card,
                                isSelected: selectedIndex == index,
                                delay: Double(index) * 0.07)
                            .onTapGesture {
                                withAnimation(.spring(duration: 0.35, bounce: 0.2)) {
                                    selectedIndex = selectedIndex == index ? nil : index
                                }
                            }
                    }

                    // Toggle all
                    Button(action: {
                        withAnimation(.spring(duration: 0.55, bounce: 0.4)) {
                            isVisible.toggle()
                        }
                    }) {
                        Label(isVisible ? "Dismiss" : "Animate In",
                              systemImage: isVisible ? "xmark.circle" : "sparkles")
                            .font(.headline)
                            .padding()
                            .frame(maxWidth: .infinity)
                            .background(.indigo)
                            .foregroundStyle(.white)
                            .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
                    }
                    .padding(.horizontal)
                }
                .padding(.vertical)
            }
            .navigationTitle("Spring Demo")
        }
        .onAppear {
            withAnimation(.spring(duration: 0.6, bounce: 0.3).delay(0.1)) {
                isVisible = true
            }
        }
    }
}

// MARK: - Sub-view

struct CardRow: View {
    let card: SpringDemoCard
    let isSelected: Bool
    let delay: Double

    @State private var didAppear = false

    var body: some View {
        HStack(spacing: 16) {
            RoundedRectangle(cornerRadius: 10, style: .continuous)
                .fill(card.color.gradient)
                .frame(width: 48, height: 48)

            VStack(alignment: .leading, spacing: 2) {
                Text(card.title).font(.headline)
                Text(card.subtitle).font(.subheadline).foregroundStyle(.secondary)
            }

            Spacer()

            Image(systemName: "chevron.right")
                .rotationEffect(isSelected ? .degrees(90) : .zero)
                .foregroundStyle(.secondary)
        }
        .padding()
        .background(
            RoundedRectangle(cornerRadius: 16, style: .continuous)
                .fill(Color(.secondarySystemBackground))
                .shadow(color: isSelected ? card.color.opacity(0.3) : .clear,
                        radius: 10, y: 4)
        )
        .scaleEffect(isSelected ? 1.02 : 1.0)
        .offset(x: didAppear ? 0 : -60)
        .opacity(didAppear ? 1 : 0)
        .padding(.horizontal)
        .onAppear {
            withAnimation(.spring(duration: 0.5, bounce: 0.25).delay(delay)) {
                didAppear = true
            }
        }
        .accessibilityLabel("\(card.title), \(card.subtitle)")
        .accessibilityAddTraits(.isButton)
    }
}

// MARK: - Preview

#Preview {
    SpringAnimationView()
}

How it works

  1. withAnimation(.spring(duration:bounce:)) — The iOS 17 overload takes a duration (settle time in seconds) and bounce (0 = critically damped, 1 = very bouncy). This replaces the deprecated response/dampingFraction pair and is far easier to reason about visually.
  2. Implicit animation propagation — Every animatable modifier (.scaleEffect, .offset, .opacity, .rotationEffect) inside the view tree that reads the mutated state will automatically participate in the same spring curve — you don't wire each one individually.
  3. Staggered entrance with .delay() — Chaining .delay(Double(index) * 0.07) onto the Animation value inside .onAppear staggers each CardRow without any timers or DispatchQueue calls.
  4. State-driven chevron rotation — The .rotationEffect on the chevron icon reads isSelected directly; wrapping the selectedIndex mutation in withAnimation makes this rotate with a spring automatically.
  5. Shadow as a spring target — The .shadow(color: isSelected ? … : .clear) also transitions with the same spring, giving cards a subtle depth spring that reinforces the scale effect without extra state.

Variants

Interactive / gesture-linked spring

Use .animation(.interactiveSpring(…), value:) when a drag gesture is driving the animation. This variant settles faster when the gesture ends, giving UI that "snaps back" rather than wobbles.

struct DraggableCard: View {
    @State private var dragOffset: CGSize = .zero
    @GestureState private var isDragging = false

    var body: some View {
        RoundedRectangle(cornerRadius: 20, style: .continuous)
            .fill(.teal.gradient)
            .frame(width: 240, height: 140)
            .scaleEffect(isDragging ? 1.05 : 1.0)
            .offset(dragOffset)
            // Snap back with an interactive spring on release
            .animation(
                .interactiveSpring(duration: 0.4, bounce: 0.3, blendDuration: 0.1),
                value: dragOffset
            )
            .gesture(
                DragGesture()
                    .updating($isDragging) { _, state, _ in state = true }
                    .onChanged { dragOffset = $0.translation }
                    .onEnded   { _ in
                        withAnimation(.spring(duration: 0.45, bounce: 0.2)) {
                            dragOffset = .zero
                        }
                    }
            )
            .accessibilityLabel("Draggable card")
    }
}

Per-view implicit spring with .animation(_:value:)

Attach .animation(.spring(duration: 0.3, bounce: 0.15), value: someState) directly to a view when you want a specific view to always spring on that state, regardless of whether the mutation site uses withAnimation. This is useful inside reusable components where you can't control the call site.

Common pitfalls

Prompt this with Claude Code

When using Soarias or Claude Code directly to implement this:

Implement spring animation in SwiftUI for iOS 17+.
Use withAnimation(.spring(duration:bounce:)) and .interactiveSpring.
Respect @Environment(\.accessibilityReduceMotion) — fall back to
.easeInOut(duration: 0.15) when true.
Make it accessible (VoiceOver labels, accessibilityAddTraits).
Add a #Preview with realistic sample data showing staggered entrance.

Drop this prompt into Soarias during the Build phase after your screens are scaffolded — Claude Code will wire up springs across your component tree and add the reduce-motion guard in one pass.

Related

FAQ

Does this work on iOS 16?

The new .spring(duration:bounce:) overload and the Spring struct are iOS 17+ only. On iOS 16 you must use the older .spring(response:dampingFraction:blendDuration:) overload. If you need to support both, gate with if #available(iOS 17, *) or set your deployment target to iOS 17 and drop the fallback entirely — which Soarias recommends for new apps in 2026.

How do I stop a spring mid-animation and reverse it?

Just toggle the state again inside a new withAnimation call — SwiftUI's animation engine automatically reads the current in-flight value and velocity, then starts a new spring from that point. There's no need to cancel timers or track intermediate values yourself. This "interrupt-and-reverse" behavior is one of the key advantages of SwiftUI's declarative animation model over UIKit's UIViewPropertyAnimator.

What's the UIKit equivalent?

In UIKit you'd use UIView.animate(withDuration:delay:usingSpringWithDamping:initialSpringVelocity:options:animations:) or, for more control, UISpringTimingParameters with a UIViewPropertyAnimator. Both require manual velocity tracking for interruption. SwiftUI's withAnimation(.spring(…)) handles all of that automatically and is significantly less code.

Last reviewed: 2026-05-11 by the Soarias team.

```