SwiftUI KeyframeAnimator in iOS 26: The Complete Guide to Multi-Track Keyframe Animations

A hands-on guide to SwiftUI KeyframeAnimator in iOS 26: multi-track parametric keyframes, when to reach for it over PhaseAnimator, and how to sync motion with haptics.

SwiftUI KeyframeAnimator Guide (iOS 26)

Updated: September 17, 2026

KeyframeAnimator is the SwiftUI view that drives one or more properties of a value through a timeline of keyframes, so you get parametric, designer-authored motion (with independent tracks for offset, scale, rotation, opacity, and so on) instead of the step-based transitions you get from withAnimation or PhaseAnimator. In iOS 26 with Xcode 26 it's still the right tool whenever a designer hands you a curve, a hold, and an overshoot. Three things a spring alone can't express. This SwiftUI KeyframeAnimator guide walks through every track type, the trigger model, performance traps, and how I wire motion to haptics on shipping apps.

  • KeyframeAnimator animates a value (any Animatable type) through KeyframeTracks that each drive one keyPath independently.
  • Four keyframe types cover every curve: LinearKeyframe, CubicKeyframe, SpringKeyframe, and MoveKeyframe (a hard jump with no interpolation).
  • Reach for PhaseAnimator when you have two or three discrete states. Reach for KeyframeAnimator when the designer specs an exact timeline with holds, overshoots, or per-axis timing.
  • The trigger parameter restarts the entire timeline. Set repeating: true for loops, or increment an Int to replay on demand.
  • Pair each keyframe's peak with .sensoryFeedback so haptics land on the same frame the animation resolves. Otherwise motion and touch feel decoupled.
  • KeyframeAnimator runs off the main animation loop. Keep the value type cheap (a struct of CGFloats) or you'll see hitches at 120Hz.

What is KeyframeAnimator in SwiftUI?

KeyframeAnimator is a view that reads an initial value, plays it through an ordered set of KeyframeTracks, and hands the interpolated value to a content closure every frame. It was introduced in iOS 17 and refined in iOS 26, and it's Apple's answer to After-Effects-style motion: a timeline where each channel (position, scale, rotation, tint) has its own curve, and where you can hold a value flat for a beat before easing into the next pose.

The important mental model shift is that you're no longer describing the from and the to. You're describing the entire journey. A designer's spec that says "scale to 1.1 over 240ms with a small overshoot, hold for 80ms, then settle to 1.0 with a soft spring" is one KeyframeTrack with three keyframes. The old approach (chaining withAnimation calls, or nesting DispatchQueue.main.asyncAfter blocks) can't express that hold or that per-frame curve without approximating.

Unlike an implicit animation, KeyframeAnimator does not react to state. It only replays when the trigger value changes. That's a feature, not a limitation. Motion timelines are inherently non-reactive, and you wouldn't want a scroll gesture accidentally restarting a fireworks sequence.

A minimal KeyframeAnimator example

Here's the smallest useful example: a heart icon that pulses on tap. This is the shape I reach for when a designer asks for a "bump" that must never feel like a plain spring. Every value below came out of a Figma prototype where the designer scrubbed the timeline until it felt right.

import SwiftUI

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

    var body: some View {
        Image(systemName: "heart.fill")
            .font(.system(size: 64))
            .foregroundStyle(.pink)
            .keyframeAnimator(
                initialValue: 1.0,
                trigger: tapCount
            ) { view, scale in
                view.scaleEffect(scale)
            } keyframes: { _ in
                LinearKeyframe(1.0, duration: 0.0)
                SpringKeyframe(1.28, duration: 0.18, spring: .snappy(duration: 0.18))
                CubicKeyframe(1.28, duration: 0.06)          // hold
                SpringKeyframe(1.0, duration: 0.34, spring: .smooth(duration: 0.34))
            }
            .onTapGesture { tapCount += 1 }
    }
}

Read the timeline top to bottom: start at 1.0, snap up to 1.28 with a snappy spring for 180ms, hold at 1.28 for 60ms so the eye can register the peak, then settle back with a softer 340ms spring. That 60ms hold is what makes the motion feel designed instead of automatic. Total duration is 580ms, which sits well inside Apple's Human Interface Guidelines window for touch feedback, but slow enough that a user perceives it as intentional response, not a bug.

Multi-track animations with KeyframeTrack

Single-value animations get you far, but the real win of KeyframeAnimator is KeyframeTrack: you animate a struct where each property has its own independent timeline. So, this is how you express motion where the scale peaks at 200ms and the rotation peaks at 350ms. Every professional micro-interaction has offset timing like this.

struct BumpValues {
    var scale: CGFloat = 1.0
    var rotation: Angle = .zero
    var yOffset: CGFloat = 0
}

struct BumpButton: View {
    @State private var trigger = 0

    var body: some View {
        Button("Add to Cart") { trigger += 1 }
            .keyframeAnimator(
                initialValue: BumpValues(),
                trigger: trigger
            ) { view, v in
                view
                    .scaleEffect(v.scale)
                    .rotationEffect(v.rotation)
                    .offset(y: v.yOffset)
            } keyframes: { _ in
                KeyframeTrack(\.scale) {
                    SpringKeyframe(1.15, duration: 0.20, spring: .snappy(duration: 0.20))
                    SpringKeyframe(1.0,  duration: 0.30, spring: .smooth(duration: 0.30))
                }
                KeyframeTrack(\.rotation) {
                    CubicKeyframe(.degrees(-4), duration: 0.10)
                    CubicKeyframe(.degrees(4),  duration: 0.12)
                    SpringKeyframe(.zero,       duration: 0.28)
                }
                KeyframeTrack(\.yOffset) {
                    SpringKeyframe(-6, duration: 0.18, spring: .snappy(duration: 0.18))
                    SpringKeyframe(0,  duration: 0.32, spring: .smooth(duration: 0.32))
                }
            }
    }
}

Notice the rotation track has three keyframes while scale and offset have two. Tracks are independent, so a value can wiggle while another simply rises and falls. The keyPath in KeyframeTrack(\.property) tells SwiftUI which field of the struct that track owns. Under the hood, KeyframeAnimator resolves each track separately, samples them at the display refresh rate, and rebuilds one BumpValues per frame before handing it to the content closure.

If your value type ever grows to more than about eight scalar fields, split the animation across two keyframeAnimator modifiers stacked on the same view. The compiler tolerates the giant closure but the runtime spends more time diffing than sampling. (I hit this exact wall on a checkout screen with a twelve-field struct, and pulling it into two animators shaved four milliseconds off every frame.)

The four keyframe types and when to use each

iOS 26 ships exactly four keyframe primitives, and every timeline is made of them. Choosing the right one is the difference between motion that feels mechanical and motion that feels crafted.

KeyframeInterpolationBest forDuration required
LinearKeyframeStraight lineConstant-velocity motion, timeline anchors, resetsYes (0.0 allowed)
CubicKeyframeCubic Bezier (auto-tangents)Ease-in / ease-out, held values, designer curvesYes
SpringKeyframeCritically-damped springOvershoots, settles, natural feelYes (spring is optional)
MoveKeyframeInstant jump, no interpolationReset points, discrete state changes mid-timelineNo (it happens between frames)

MoveKeyframe is the least-used but most powerful for micro-interactions. Use it when you need to teleport a value. For example, to reset a rotation from 360° back to 0° between spin cycles without visually unwinding. CubicKeyframe is what you want when a designer sends an ease curve as SVG cubic-bezier control points; SwiftUI auto-tangents adjacent cubics so long ease chains feel continuous.

SpringKeyframe takes an optional spring: parameter. I default to .snappy(duration: 0.24, extraBounce: 0.10) for pickups and .smooth(duration: 0.34) for settles. When you omit the spring, SwiftUI infers a critically-damped one from the duration, which is safe but not distinctive.

KeyframeAnimator vs PhaseAnimator

They look similar at first, but they solve orthogonal problems. If you're choosing between them, ask: is my animation a sequence of discrete states, or a timeline of parametric values? Discrete states go to PhaseAnimator (see our PhaseAnimator multi-step animations guide). Timelines with holds, per-track timing, or curves you scrubbed in Figma go to KeyframeAnimator.

ConcernKeyframeAnimatorPhaseAnimator
ModelTimeline of keyframes over an Animatable valueOrdered phases with per-phase animation and value
Independent tracksYes, one per property via KeyframeTrackNo, one shared animation per phase transition
Holds / dwell timeNative (use a zero-delta keyframe)Requires a phase whose value equals the previous phase
Loopsrepeating: true flagManual (increment trigger from onChange)
Peak countUnlimitedPractical limit ~4 phases before code becomes hard to read
Best forBumps, spinners, chart entrances, hero sequencesState machines (idle → loading → success)

A common pattern I've shipped: PhaseAnimator drives the state (idle vs loading), and inside the loading state a KeyframeAnimator plays the spinner timeline. They compose cleanly because their triggers are independent.

Triggers, loops, and replay control

The trigger parameter takes any Equatable value. Whenever it changes, the entire timeline restarts from the initial value. In practice I use one of three patterns:

  1. Increment an Int for user-driven replays like tap feedback. tapCount += 1 is idiomatic and cheap.
  2. Pass a Bool that toggles when a request finishes, so the animation runs once per state change.
  3. Pass a Date to guarantee uniqueness for programmatic replays (for example, when a server-pushed event asks for a celebration).

For continuous loops, use the repeating: initializer. This variant plays the timeline forever until the view leaves the hierarchy, which is exactly what you want for a spinner or an ambient background pulse:

Circle()
    .fill(.blue.opacity(0.6))
    .frame(width: 24, height: 24)
    .keyframeAnimator(
        initialValue: 1.0,
        repeating: true
    ) { view, scale in
        view.scaleEffect(scale)
    } keyframes: { _ in
        CubicKeyframe(1.0, duration: 0.0)
        SpringKeyframe(1.4, duration: 0.6, spring: .smooth(duration: 0.6))
        SpringKeyframe(1.0, duration: 0.8, spring: .smooth(duration: 0.8))
    }

Two gotchas. First, repeating: true does not accept a trigger; the loop is unconditional. If you need a pausable loop, wrap the view in an if so it leaves and rejoins the hierarchy. Second, when the view scrolls off-screen the animation is throttled by SwiftUI's render loop. It doesn't consume CPU, but the phase you resume at is undefined. Don't rely on visual state persisting across visibility changes.

Syncing haptics with keyframe peaks

Motion without matching haptics feels flat on iPhone. The rule I follow: every visual peak that a user consciously waits for should have a paired haptic event, and both should land on the same frame. SwiftUI ships two hooks for this: .sensoryFeedback for one-shot Taptic Engine events, and CHHapticEngine for AHAP patterns synchronized to longer timelines.

The simple case pairs the keyframe trigger with a .sensoryFeedback. Both key off the same Equatable value, so they fire on the same runloop tick:

@State private var tapCount = 0

var body: some View {
    Image(systemName: "star.fill")
        .keyframeAnimator(initialValue: 1.0, trigger: tapCount) { view, scale in
            view.scaleEffect(scale)
        } keyframes: { _ in
            SpringKeyframe(1.35, duration: 0.20, spring: .snappy(duration: 0.20))
            SpringKeyframe(1.0,  duration: 0.34, spring: .smooth(duration: 0.34))
        }
        .sensoryFeedback(.impact(weight: .medium, intensity: 0.85), trigger: tapCount)
        .onTapGesture { tapCount += 1 }
}

For anything longer than about 300ms, one impact isn't enough. The user perceives the haptic and the motion as separate events. Use a CHHapticEngine AHAP pattern whose event timestamps line up with your keyframe durations. If your bump peaks at 200ms, the haptic transient goes at 0.20 seconds; if a hold ends at 260ms, put a short continuous haptic covering 200ms to 260ms. Our SwiftUI Haptics guide walks through the AHAP file format and playback plumbing.

Performance and pitfalls at 120Hz

KeyframeAnimator samples every property on every display refresh. On a ProMotion iPhone that's 120 samples per second per track, per active animator. Cheap on paper, expensive in aggregate. Two things I check before shipping any KeyframeAnimator-heavy screen:

  1. Keep the animated value tiny. A struct of scalars is fine. A struct that holds a Color, a String, or worse an Array will allocate every sample. If you need to animate a gradient, animate the stops as CGFloats and rebuild the gradient inside content.
  2. Prefer transform modifiers over layout modifiers. .scaleEffect, .rotationEffect, .offset, and .opacity hit the GPU. Animating .frame(width:) or .padding re-lays out every parent on every frame; on a busy list this drops you to 40fps immediately. Profile with the SwiftUI Instruments template. See our Xcode Instruments for SwiftUI guide for the setup.

Two more subtle traps. First, MoveKeyframe counts as a frame boundary; Apple's Metal-backed compositor will emit a discontinuity marker, so pair it with an immediate LinearKeyframe(value, duration: 0) if you want the value to visually persist. Second, don't put a KeyframeAnimator inside a ForEach that resizes. Honestly, I got burned by this one shipping an iPad app: every insertion resets every animator's initial value, which manifests as flickers during rotation. Anchor the animator on a stable identity by pulling it into its own subview.

Frequently Asked Questions

What is the difference between KeyframeAnimator and PhaseAnimator in SwiftUI?

PhaseAnimator moves a value through discrete phases with one animation per transition, which is ideal for state machines like idle to loading to done. KeyframeAnimator plays a timeline of parametric keyframes over an Animatable value, with independent tracks per property. Use it whenever a designer specifies exact durations, holds, or per-axis timing.

Can KeyframeAnimator loop indefinitely?

Yes. Use the repeating: true initializer instead of the trigger-based one. The timeline replays continuously until the view leaves the hierarchy. Note that repeating: true does not accept a trigger, so to pause a loop you have to conditionally remove the view.

Does KeyframeAnimator work with any Swift type?

Any type that conforms to Animatable. All of SwiftUI's built-in animatable values (CGFloat, Double, CGSize, Angle, Color, and any struct of those) work out of the box. For custom types, conform to Animatable and expose an animatableData vector.

Why is my KeyframeAnimator animation choppy on device?

Almost always because the animated modifier triggers layout. Swap .frame(width:) or .padding for .scaleEffect or .offset. Also confirm the animated value struct holds only cheap scalars, with no Colors, Arrays, or Strings, since KeyframeAnimator allocates a new value per frame at up to 120Hz on ProMotion.

How do you sync haptics with SwiftUI animations?

Fire the haptic from the same Equatable trigger that drives the animation. For one-shot bumps, add .sensoryFeedback(.impact, trigger: sameValue) next to the .keyframeAnimator. Both fire on the same runloop tick. For animations longer than 300ms, use a Core Haptics AHAP pattern whose event timestamps match your keyframe durations.

Diana Kowalski
About the Author Diana Kowalski

Mobile UX engineer translating design intent into pixel-perfect SwiftUI. Has strong opinions about haptics.