How to implement spring animation in SwiftUI
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
-
withAnimation(.spring(duration:bounce:)) — The iOS 17 overload takes a
duration(settle time in seconds) andbounce(0 = critically damped, 1 = very bouncy). This replaces the deprecatedresponse/dampingFractionpair and is far easier to reason about visually. -
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. -
Staggered entrance with .delay() — Chaining
.delay(Double(index) * 0.07)onto theAnimationvalue inside.onAppearstaggers eachCardRowwithout any timers orDispatchQueuecalls. -
State-driven chevron rotation — The
.rotationEffecton the chevron icon readsisSelecteddirectly; wrapping theselectedIndexmutation inwithAnimationmakes this rotate with a spring automatically. -
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
-
Using the old
.spring(response:dampingFraction:blendDuration:)overload on iOS 17+. It still compiles but is deprecated. Switch to.spring(duration:bounce:)or the newSpringstruct. Xcode 16 will warn you. -
Animating inside
.taskorasynccontexts without hopping to the main actor. State mutations from async tasks must be on the@MainActor. Wrap withawait MainActor.run { withAnimation(…) { … } }or mark the calling function@MainActor. -
Over-bouncing hurts accessibility. Users with "Reduce Motion" enabled expect
minimal movement. Always check
@Environment(\.accessibilityReduceMotion)and fall back to a short.easeInOut(duration: 0.15)when it'strue.
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.