SwiftUI Animation: From withAnimation to Keyframes and Animatable
The animation modifiers you'll actually reach for — value-based .animation, withAnimation blocks, transitions for insert/remove, matchedGeometryEffect for hero moves, springs vs curves, and the newer completion, phase, and keyframe APIs. Real Swift 6 code.
Animate state changes two ways: attach .animation(_:value:) to animate whenever a specific value changes, or wrap the mutation in withAnimation { } to animate that change explicitly. Use .transition for views entering and leaving, matchedGeometryEffect to morph one view into another, and prefer spring animations for natural motion. iOS 17+ adds an animation completion callback plus phase and keyframe animators, and iOS 26 introduces the @Animatable macro.
The two ways to trigger an animation
SwiftUI gives you two entry points and choosing correctly avoids most animation surprises. The value-based modifier .animation(_:value:) animates any change that results from a specific value mutating — attach it and whenever that value changes, the affected properties interpolate. The imperative form, withAnimation { }, animates the state changes you make inside the closure and nothing else, which gives you precise scope. The old .animation(_:) with no value is deprecated because it animated indiscriminately, causing unrelated changes to move. Reach for withAnimation when a single event should animate several properties, and .animation(_:value:) when a property should always animate as it changes.
struct TriggerDemo: View {
@State private var expanded = false
var body: some View {
VStack(spacing: 24) {
// Always animates when `expanded` changes:
RoundedRectangle(cornerRadius: 12)
.frame(width: expanded ? 240 : 120, height: 80)
.animation(.spring, value: expanded)
Button("Toggle") {
// Explicitly scope the animation to this change:
withAnimation(.snappy) { expanded.toggle() }
}
}
}
}Transitions for insert and remove
Position and size changes interpolate automatically, but a view appearing or disappearing needs a transition to describe how. Attach .transition to a view that is conditionally present, then toggle its presence inside an animation so SwiftUI knows to animate the insertion and removal. Built-ins include .opacity, .scale, .slide, and .move(edge:), and you combine them with .combined(with:) or split insertion and removal using .asymmetric. A frequent mistake is expecting a transition to run without an animating container — the condition that adds or removes the view must change inside withAnimation (or under an .animation(value:)) for the transition to play at all.
struct BannerDemo: View {
@State private var showBanner = false
var body: some View {
VStack {
Button("Toggle Banner") {
withAnimation(.easeInOut) { showBanner.toggle() }
}
if showBanner {
Text("Saved!")
.padding()
.background(.green.opacity(0.2), in: Capsule())
.transition(.asymmetric(
insertion: .move(edge: .top).combined(with: .opacity),
removal: .opacity))
}
}
}
}matchedGeometryEffect for hero moves
matchedGeometryEffect creates the illusion of a single view moving between two places — a thumbnail expanding into a detail view, a tab indicator sliding under the selected tab. You give two views the same id within a shared @Namespace, and when one is removed and the other inserted, SwiftUI interpolates position and size between them so it reads as one element traveling. The key rules: both views must share the same namespace and id, only one should be present at a time for a clean morph, and the change must happen inside an animation. It's the tool behind most polished 'hero' transitions and the animated selection pill in custom tab bars.
struct SegmentedPicker: View {
@Namespace private var ns
@State private var selection = 0
let titles = ["Day", "Week", "Month"]
var body: some View {
HStack {
ForEach(titles.indices, id: \.self) { i in
Text(titles[i])
.padding(.vertical, 8).padding(.horizontal, 16)
.background {
if selection == i {
Capsule().fill(.blue.opacity(0.2))
.matchedGeometryEffect(id: "pill", in: ns)
}
}
.onTapGesture { withAnimation(.snappy) { selection = i } }
}
}
}
}Spring vs easeInOut, and the completion API
For UI that responds to touch, springs feel more natural than fixed-duration curves because they carry momentum. Modern SwiftUI offers named springs — .spring, .bouncy, .snappy, .smooth — that you can use without tuning stiffness and damping by hand; reserve custom Spring(response:dampingFraction:) for when a named one isn't quite right. Reserve .easeInOut and friends for deterministic, non-interactive motion like a progress sweep. Since iOS 17 you can also run code when an animation finishes via the completion overload of withAnimation, which finally removes the old trick of guessing a delay to chain follow-up work after an animation lands.
- Springs (.snappy, .bouncy, .smooth) for touch-driven motion
- .easeInOut / .linear for deterministic, non-interactive sweeps
- withAnimation(_:completion:) runs code when the animation completes (iOS 17+)
- Avoid hard-coded delays to sequence animations
struct CompletionDemo: View {
@State private var scale = 1.0
@State private var showDone = false
var body: some View {
Circle().frame(width: 80, height: 80).scaleEffect(scale)
.onTapGesture {
withAnimation(.bouncy) { scale = 1.4 } completion: {
withAnimation { showDone = true }
}
}
}
}Phase, keyframe animators, and Animatable in iOS 26
For multi-step animations, iOS 17 added two declarative tools. PhaseAnimator cycles a view through an array of phases, letting you describe distinct states (rest, jiggle, settle) and the animation between each — perfect for looping attention effects. KeyframeAnimator drives multiple properties along independent timelines, the way you'd storyboard a bouncing icon whose scale, rotation, and offset peak at different moments. Both keep complex motion declarative instead of nesting DispatchQueue delays. Looking ahead, iOS 26 introduces the @Animatable macro, which synthesizes the animatableData conformance for your custom Shapes and views so you can animate custom properties without hand-writing the AnimatablePair boilerplate.
struct PulseIcon: View {
var body: some View {
Image(systemName: "bell.fill")
.font(.largeTitle)
.phaseAnimator([1.0, 1.2, 1.0]) { view, scale in
view.scaleEffect(scale)
} animation: { _ in
.easeInOut(duration: 0.6)
}
}
}
// iOS 26: @Animatable synthesizes animatableData for custom shapes
// @Animatable struct Wave: Shape { var phase: Double; func path(in rect: CGRect) -> Path { /* ... */ Path() } }Consistent motion, defined once
The Swift Kit centralizes animation curves and durations as design-system tokens, so every screen transition and button press shares the same polished feel.
How to build a hero transition between two views
matchedGeometryEffect makes a thumbnail appear to expand into a detail card.
- 1
Create a namespace
Declare a shared @Namespace in the parent that owns both states.
@Namespace private var ns - 2
Tag both views
Give the collapsed and expanded views the same id in that namespace.
.matchedGeometryEffect(id: item.id, in: ns) - 3
Toggle inside an animation
Switch which view is shown within withAnimation so SwiftUI interpolates.
withAnimation(.spring) { selectedItem = item } - 4
Show one at a time
Ensure only the collapsed or expanded view is present for a clean morph.
if selectedItem == nil { Thumbnail() } else { DetailCard() }
Frequently Asked Questions
Why does my SwiftUI .transition not animate when a view appears or disappears?
What's the difference between .animation(_:value:) and withAnimation in SwiftUI?
How do I run code after a SwiftUI animation finishes instead of guessing a delay?
When should I use PhaseAnimator versus KeyframeAnimator in SwiftUI?
What does the iOS 26 @Animatable macro do for custom SwiftUI shapes?
Keep exploring
Motion that feels designed, not bolted on
The Swift Kit is a $99 one-time SwiftUI boilerplate with a token-driven design system, onboarding, and paywalls — so animations stay consistent as your app grows.
Get The Swift Kit — $99One-time purchase · Lifetime updates · 14-day refund