SwiftUI PhaseAnimator in iOS 26: Multi-Step Animation Sequences Without State Machines

SwiftUI's PhaseAnimator walks a view through ordered animation phases from a single trigger, replacing @State flags and asyncAfter chains. Learn per-phase curves, trigger patterns, and how to combine it with CustomAnimation, plus real-world code for like buttons and toasts.

Updated: August 29, 2026

SwiftUI PhaseAnimator is a view modifier in iOS 17+ (fully mature in iOS 26) that walks a view through an ordered sequence of animation phases from a single trigger value, with a per-phase animation curve, replacing the tangle of @State flags, DispatchQueue.asyncAfter chains, and animation completion closures you used to need for multi-step effects. In practice you declare a CaseIterable enum of phases, hand it to .phaseAnimator(_:trigger:), and SwiftUI advances through the phases whenever the trigger changes, applying each phase's transform with the animation you specify. This guide is the deep dive I wish existed when I first shipped a like-button bounce (that story ends with me deleting about 60 lines of asyncAfter glue and finally sleeping well).

  • PhaseAnimator replaces manual state machines for staged animations by iterating a CaseIterable enum on every trigger change.
  • The animation(_:for:) closure returns a different Animation per phase, so wind-up, snap, and settle can each use their own curve.
  • KeyframeAnimator is the right tool when properties animate on overlapping, independent timelines; PhaseAnimator is for sequential discrete phases.
  • Every phase animator you ship must respect @Environment(\.accessibilityReduceMotion). Collapse to opacity or skip the sequence entirely.
  • The trigger is any Equatable; passing a UUID() forces a replay even when logical state is unchanged.
  • iOS 26 combines beautifully with symbolEffect, contentTransition, and Metal colorEffect shaders for a layered feel.

What is PhaseAnimator in SwiftUI?

PhaseAnimator is a SwiftUI view modifier that drives a view through an ordered sequence of animation phases when a trigger value changes. Introduced at WWDC 2023 and now stable in iOS 26, it lets you describe a multi-step animation as data: an enumeration of named phases, a closure that maps each phase to a set of view modifiers, and an optional per-phase Animation. The system handles the "when to advance" and "which curve applies right now" bookkeeping.

Before PhaseAnimator, a three-step like-button bounce meant three @State booleans, two DispatchQueue.main.asyncAfter calls, and the constant risk of the animation getting stuck if the user tapped mid-sequence. Now it's a single enum LikePhase: CaseIterable { case rest, launch, snap } and one modifier. In production I've seen these refactors delete 40 to 70 lines of imperative code while running smoother, because SwiftUI handles interruption via Transaction merges rather than dropped timers.

The mental model is: "given a trigger has just changed, walk forward through every case of the enum, applying the animation returned for the case you're leaving." When the last case finishes, the sequence pauses on the final phase until the next trigger change, at which point it restarts from the first case.

Basic usage: your first phase enum

Here's the minimum viable PhaseAnimator. A heart icon scales up, wobbles, and settles when the user taps it:

import SwiftUI

enum LikePhase: CaseIterable {
    case rest, expand, wobble
}

struct LikeButton: View {
    @State private var tapCount = 0

    var body: some View {
        Button {
            tapCount += 1
        } label: {
            Image(systemName: "heart.fill")
                .font(.system(size: 44))
                .foregroundStyle(.pink)
                .phaseAnimator(LikePhase.allCases, trigger: tapCount) { heart, phase in
                    heart
                        .scaleEffect(phase == .expand ? 1.4 : phase == .wobble ? 1.1 : 1.0)
                        .rotationEffect(.degrees(phase == .wobble ? -8 : 0))
                }
        }
        .accessibilityLabel("Like")
        .accessibilityAddTraits(.isButton)
    }
}

Two things are worth flagging. First, the closure receives both the wrapped view (heart) and the current phase, so the same view definition serves every phase. You switch on phase to change modifiers. Second, the trigger is tapCount, not a boolean. Booleans stop firing after two taps in a row (true → true doesn't count as a change), and an incrementing counter always fires.

Per-phase timing curves

So, here's where the API earns its keep. The single-argument form uses a default spring for every phase, which is fine for demos and terrible for anything shipping. Real animation feels tuned because each phase gets its own curve: a stiff wind-up, a snappy release, a soft settle. The three-argument phaseAnimator(_:trigger:content:animation:) takes an animation closure that returns the Animation to use when leaving the given phase:

Image(systemName: "heart.fill")
    .phaseAnimator(
        LikePhase.allCases,
        trigger: tapCount
    ) { heart, phase in
        heart
            .scaleEffect(scale(for: phase))
            .rotationEffect(.degrees(rotation(for: phase)))
    } animation: { phase in
        switch phase {
        case .rest:   .easeIn(duration: 0.12)   // wind-up
        case .expand: .spring(response: 0.22, dampingFraction: 0.55)
        case .wobble: .spring(response: 0.35, dampingFraction: 0.75)
        }
    }

Read that carefully: the animation returned for .rest is the animation that plays as the view leaves rest and enters .expand. The last case's animation (here .wobble) determines the settle back to .rest on the next trigger. Honestly, I mix up which case owns which transition roughly once per project, so put a comment on the switch if it isn't obvious from the case names.

For interruption safety, prefer springs over durations. If the user taps mid-sequence and SwiftUI has to interpolate from an in-flight scale to a new target, a spring reaches the new value smoothly. A fixed-duration ease can snap. This matters most on the settle phase, which is the one most likely to be interrupted.

Trigger patterns that actually work

The trigger is any Equatable, and choosing the right one is 80% of getting PhaseAnimator right. Four patterns cover almost everything:

  • Incrementing Int: the default. tapCount += 1 in every action guarantees a change.
  • Fresh UUID: for "force replay regardless of state." Store @State private var replayID = UUID() and set replayID = UUID() to fire.
  • Domain value: the trigger is the interesting model change, e.g. trigger: order.status. The animation runs whenever the order transitions state.
  • Composed tuple: trigger: [selectedTab, unreadCount] to react to either dimension changing.

A subtle gotcha: .onAppear does not fire the animation, because there's no trigger change on initial render. If you want an entrance animation, either start the trigger from a "loading" value and set it to "ready" in .task, or reach for .transition instead.

PhaseAnimator vs KeyframeAnimator

These two APIs shipped together and get confused constantly. The short version: PhaseAnimator is for sequential discrete phases; KeyframeAnimator is for parallel continuous timelines.

DimensionPhaseAnimatorKeyframeAnimator
ModelEnum of phases, iterated in orderMultiple keyframe tracks per property
Best forBounces, taps, staged revealsChoreographed motion where scale, rotation, offset each have their own curve
TimingOne animation per phase transitionCubic, spring, linear, or move keyframes per property
TriggerAny Equatable; iterates on changePlays once per trigger; can loop with a wrapper
InterruptionInterpolates via Transaction merge (spring recommended)Restarts from the interrupted values
API surface.phaseAnimator(_:trigger:).keyframeAnimator(initialValue:trigger:content:keyframes:)
ComplexityLow (enum plus one modifier)Higher (value type, tracks per property)

Rule of thumb I've settled on after a few years with both. If the whole animation reads naturally as "step 1, step 2, step 3," reach for PhaseAnimator. If it reads as "scale ramps from 0 to 1.2 to 1.0 while rotation eases from -20° to 0° while opacity fades in over the first half," it's KeyframeAnimator. The Apple docs on PhaseAnimator have the reference; the practical taste for choosing between them comes from a couple of weeks of shipping with each. For deeper coverage of the keyframe side and general animation composition, see the SwiftUI Animations guide covering springs, keyframes, and transitions. If you're new to SwiftUI's broader iOS 26 design system, the Liquid Glass in SwiftUI guide pairs nicely with these motion patterns.

Combining PhaseAnimator with CustomAnimation

When the built-in curves don't do what you want (a bounce that overshoots exactly 12%, a squash-and-stretch decay), you can conform to the CustomAnimation protocol and hand it back from the animation closure. This is the escape hatch for animation obsessives:

struct RubberBand: CustomAnimation {
    let amplitude: Double
    let period: Double

    func animate<V: VectorArithmetic>(
        value: V, time: TimeInterval, context: inout AnimationContext<V>
    ) -> V? {
        guard time < period else { return nil }
        let decay = exp(-4 * time / period)
        let wave = sin(2 * .pi * time / period * 3)
        let progress = 1 - decay * wave * amplitude
        var scaled = value
        scaled.scale(by: progress)
        return scaled
    }
}

extension Animation {
    static var rubberBand: Animation {
        Animation(RubberBand(amplitude: 0.12, period: 0.6))
    }
}

// use it inside phaseAnimator:
} animation: { phase in
    phase == .expand ? .rubberBand : .spring(response: 0.3, dampingFraction: 0.8)
}

Returning nil from animate(value:time:context:) signals completion, which lets PhaseAnimator advance to the next phase. This is the seam where the two APIs meet: PhaseAnimator is the sequencer, and CustomAnimation is the curve engine. For text-focused animation you can pair the sequence with a contentTransition modifier for text, numeric, and symbol animations, and for SF Symbol punctuation the symbolEffect API on SF Symbols composes cleanly with a phase-driven scale.

Respecting reduceMotion and VoiceOver

Every animation you ship needs to survive Settings → Accessibility → Motion → Reduce Motion. PhaseAnimator does not opt in to that automatically. Read the environment and either collapse the sequence to a cross-fade or skip animation entirely:

struct AccessibleLikeButton: View {
    @Environment(\.accessibilityReduceMotion) private var reduceMotion
    @State private var tapCount = 0

    var body: some View {
        Image(systemName: "heart.fill")
            .font(.system(size: 44))
            .foregroundStyle(.pink)
            .phaseAnimator(LikePhase.allCases, trigger: tapCount) { heart, phase in
                heart
                    .scaleEffect(reduceMotion ? 1.0 : scale(for: phase))
                    .opacity(reduceMotion ? (phase == .expand ? 0.6 : 1.0) : 1.0)
            } animation: { _ in
                reduceMotion ? .easeInOut(duration: 0.15) : .spring(response: 0.3, dampingFraction: 0.6)
            }
    }
}

The rule I've adopted: when reduce motion is on, no scale changes, no rotation, no translation greater than a few points. A subtle opacity pulse still communicates "something happened" without triggering vestibular symptoms. Apple's Human Interface Guidelines on motion spell out the intent. Flashing, parallax, and dramatic scaling are the categories to avoid.

On the VoiceOver side, decorative animation phases don't need to announce anything, but any state change that the animation is communicating must be reflected in an accessibilityValue or an accessibilityLabel update. If your like button goes red on tap, VoiceOver users need the label to change from "Like" to "Liked". Animation is an enhancement, not a replacement for semantics. For a broader treatment, see the SwiftUI accessibility guide covering VoiceOver, Dynamic Type, and inclusive design.

Real-world patterns: like button, toast, undo shake

Three sequences I reach for constantly, all under 30 lines each.

Toast entrance and dismiss

enum ToastPhase: CaseIterable { case hidden, visible, dismissing }

struct Toast: View {
    let message: String
    @Binding var shown: Bool

    var body: some View {
        Text(message)
            .padding(.horizontal, 16).padding(.vertical, 10)
            .background(.regularMaterial, in: .capsule)
            .phaseAnimator(ToastPhase.allCases, trigger: shown) { toast, phase in
                toast
                    .offset(y: phase == .hidden ? -60 : phase == .dismissing ? -40 : 0)
                    .opacity(phase == .visible ? 1 : 0)
            } animation: { phase in
                switch phase {
                case .hidden:     .spring(response: 0.35, dampingFraction: 0.75)
                case .visible:    .easeIn(duration: 0.2).delay(2.0)
                case .dismissing: .easeOut(duration: 0.25)
                }
            }
    }
}

The .delay(2.0) on the .visible transition is what holds the toast on screen. No Task.sleep, no timer, no cancellation logic.

Undo shake

enum ShakePhase: CaseIterable { case rest, left, right, center }

TextField("Email", text: $email)
    .phaseAnimator(ShakePhase.allCases, trigger: validationFailedCount) { field, phase in
        field.offset(x: phase == .left ? -8 : phase == .right ? 8 : 0)
    } animation: { _ in
        .spring(response: 0.08, dampingFraction: 0.5)
    }

Increment validationFailedCount whenever validation fails and the field shakes. Set reduceMotion to short-circuit to a red border instead.

Sequential reveal

For onboarding sequences with 5 to 10 steps, PhaseAnimator's discrete-phase model shines. Each phase moves one element in; the closure switches on the phase to decide which. This is where the ability to name phases pays off. "stepOneVisible" reads at a glance, "3" doesn't.

Common pitfalls in iOS 26

Four failure modes I hit repeatedly and now defend against on autopilot:

  1. Boolean triggers that don't fire. A Bool flipped back to its previous value does not count as a change. Use a counter or a UUID.
  2. Sequence gets stuck mid-animation. Usually caused by another @State mutation invalidating the view tree during a phase. Wrap the mutating code in withAnimation(nil) if it should not perturb the phase sequence.
  3. Ignored reduceMotion. App Store review has flagged this on a few of my submissions. Always read the environment.
  4. Phase enum with too many cases. Once you're over 5 phases, you're describing choreography, not sequence. Switch to KeyframeAnimator.

For a deeper look at where PhaseAnimator fits alongside hero transitions, the matchedGeometryEffect guide to hero animations covers the shared-element side of the same problem space. And if you're chasing frame drops, the Xcode Instruments guide to profiling hangs and frame drops walks through the SwiftUI Animations track that will surface any phase misconfiguration as a stutter.

Frequently Asked Questions

Does PhaseAnimator loop indefinitely?

Not by default. It runs the phase sequence once per trigger change and then pauses on the final phase. To loop, fire the trigger repeatedly (e.g. with a Timer.publish subscription that increments a counter) or wrap the sequence in a TimelineView for time-driven repetition.

Can I use PhaseAnimator with @Observable models?

Yes. Pass any Equatable property of the observable as the trigger. Because @Observable triggers view re-renders on property access, the phase animator sees the change immediately and advances the sequence. Prefer stable, meaningful properties (order status, unread count) over ephemeral values.

What is the difference between PhaseAnimator and withAnimation?

withAnimation animates a single state change with one curve. PhaseAnimator chains multiple discrete steps, each with its own curve, from one trigger. It's the equivalent of scheduling several withAnimation calls at precise offsets, but without the timers and completion callbacks.

Can PhaseAnimator run in a Widget?

No. Widgets do not support arbitrary SwiftUI animation; only the small set of widget-approved animations (transitions between timeline entries) applies. Use a full app or Live Activity when you need choreographed motion.

How do I test a PhaseAnimator in Xcode Previews?

Wrap the previewed view in a container with a @State trigger and a button that mutates it. Previews render the phases on tap. For deterministic snapshots, capture at a specific phase by binding the trigger to a fixed value and using .animation(nil, value:) to skip the transition.

Ava Thompson
About the Author Ava Thompson

SwiftUI engineer focused on declarative animations and accessibility. Will fight you about navigation stacks.