SwiftUI gives you two ways to animate a state change, and they read almost identically at the call site:

Two waysboth compile
// 1. Animate the change itself.
withAnimation(.spring(response: 0.34, dampingFraction: 0.55)) {
    on.toggle()
}

// 2. Animate whatever depends on this value, whenever it changes.
Capsule()
    .fill(on ? Color.green : Color.gray)
    .animation(.spring(response: 0.34, dampingFraction: 0.55), value: on)

The difference is where the decision lives. withAnimation wraps a mutation: whoever changes the state decides how it animates. The modifier attaches to a view: whoever draws the view decides.

The rule

Use the modifier when the animation belongs to the component. Use withAnimation when it belongs to the event.

A toggle that always springs is a property of the toggle, so it goes on the view. A list that animates only when the change came from a pull-to-refresh, and not when it came from a background sync, is a property of that event — so it goes at the call site.

Why value: is not optional

The old .animation(_:) with no value was deprecated in iOS 15 for a good reason: it animated every change to anything above it in the view tree, including ones you never thought about. A colour change, a resize on rotation, a parent re-laying out — all of it went through your spring.

value: makes it explicit. This animation runs when this changes, and stays out of the way otherwise.

A worked example

A toggle whose knob overshoots. The animation is part of what the toggle is, so it lives on the view.

BouncyToggle.swiftplays as written
import SwiftUI

struct BouncyToggle: View {
    @State private var on = false

    var body: some View {
        Button {
            on.toggle()
        } label: {
            Capsule()
                .fill(on ? Color.green : Color.gray.opacity(0.3))
                .frame(width: 50, height: 30)
                .overlay(alignment: on ? .trailing : .leading) {
                    Circle()
                        .fill(.white)
                        .frame(width: 24, height: 24)
                        .padding(3)
                }
        }
        .animation(.spring(response: 0.34, dampingFraction: 0.55), value: on)
    }
}

Two things are being animated by that one modifier and one state change: the capsule's fill colour, and the knob's alignment from leading to trailing. You never wrote a position — SwiftUI animates the layout difference between the two states.

Reading a spring

Springs are described by feel rather than by curve:

ArgumentWhat it doesUseful range
responseRoughly how long the move takes0.2 snappy, 0.6 lazy
dampingFractionHow much it overshootsBelow 0.7 bounces, 1.0 doesn't

If a change should feel mechanical, use .easeInOut. If it should feel like an object with weight, use a spring — and if it should feel alive, drop the damping fraction under 0.6.

When neither applies

Both of these interpolate between two states. Continuous motion — an orbit, a clock hand, anything that is a function of time rather than a transition — is not a state change at all, and needs TimelineView(.animation) instead. There is an example in the animation examples.

Try it

Open Swoop, paste BouncyToggle, and walk dampingFraction from 0.9 down to 0.35 while tapping it. The number stops being abstract about halfway down.