SwiftUI Animation in 2026: Phase Animators, Keyframes, and Core Animation
Choose the right animation layer for a production SwiftUI app: ordinary state animation, PhaseAnimator, KeyframeAnimator, or a focused Core Animation bridge.
SwiftUI now covers most interface animation without requiring direct layer code. The production decision is no longer simply "SwiftUI or Core Animation." It is a ladder: start with an ordinary state-driven animation, move to discrete phases when the interaction has named steps, use keyframes when properties need an explicit timeline, and bridge to Core Animation only for capabilities the SwiftUI layer does not expose.
That order matters. Starting too low creates imperative state that fights SwiftUI updates. Staying too high can produce fragile workarounds, repeated layout, or animation that cannot synchronize with the system it represents.
The Four Layers to Consider
1. State-driven animation
Use withAnimation or .animation(_:value:) when one state change should interpolate to another:
struct DisclosureCard: View {
@State private var isExpanded = false
var body: some View {
CardContent(isExpanded: isExpanded)
.scaleEffect(isExpanded ? 1 : 0.96)
.opacity(isExpanded ? 1 : 0.7)
.onTapGesture {
withAnimation(.snappy) {
isExpanded.toggle()
}
}
}
}
This is the best default for selection, disclosure, insertion, and other transitions with two meaningful states. Do not introduce a timeline API merely because the motion uses more than one modifier.
2. PhaseAnimator
A phase animator models a finite sequence of discrete states. SwiftUI advances through the phases and applies the animation selected for each transition.
private enum SavePhase: CaseIterable {
case idle, compress, settle
var scale: Double {
switch self {
case .idle: 1
case .compress: 0.88
case .settle: 1.05
}
}
}
struct SaveConfirmation: View {
@State private var saveCount = 0
var body: some View {
Image(systemName: "checkmark.circle.fill")
.phaseAnimator(SavePhase.allCases, trigger: saveCount) { content, phase in
content.scaleEffect(phase.scale)
} animation: { phase in
switch phase {
case .idle: .smooth
case .compress: .easeIn(duration: 0.12)
case .settle: .spring(duration: 0.35, bounce: 0.35)
}
}
.onTapGesture { saveCount += 1 }
}
}
Use phases for a pulse, a status transition, a short onboarding beat, or feedback that has a small set of named visual states. Prefer an enum over raw numbers: it makes the sequence readable and prevents animation values from becoming unexplained constants.
Phase animation is not a substitute for a precise timeline. It tells SwiftUI which states to visit, not the exact value of every property at an arbitrary timestamp.
3. KeyframeAnimator
A keyframe animator is appropriate when multiple properties need independent tracks over a coordinated timeline. SwiftUI evaluates the content closure on every animation frame, so keep that closure limited to inexpensive visual modifiers.
private struct FeedbackValues {
var scale = 1.0
var rotation = Angle.zero
var verticalOffset = 0.0
}
struct KeyframedFeedback: View {
@State private var trigger = 0
var body: some View {
Image(systemName: "star.fill")
.keyframeAnimator(
initialValue: FeedbackValues(),
trigger: trigger
) { content, value in
content
.scaleEffect(value.scale)
.rotationEffect(value.rotation)
.offset(y: value.verticalOffset)
} keyframes: { _ in
KeyframeTrack(\.scale) {
SpringKeyframe(1.25, duration: 0.28, spring: .bouncy)
CubicKeyframe(1, duration: 0.2)
}
KeyframeTrack(\.rotation) {
CubicKeyframe(.degrees(10), duration: 0.18)
CubicKeyframe(.zero, duration: 0.3)
}
KeyframeTrack(\.verticalOffset) {
CubicKeyframe(-12, duration: 0.2)
SpringKeyframe(0, duration: 0.28, spring: .bouncy)
}
}
.onTapGesture { trigger += 1 }
}
}
Apple provides four useful keyframe types:
LinearKeyframefor constant interpolation to the next valueCubicKeyframefor a smooth cubic curveSpringKeyframefor spring-based interpolationMoveKeyframefor an immediate value change without interpolation
Use keyframes for coordinated feedback, a composed icon transition, or a short celebratory sequence. Avoid disk access, decoding, model work, object allocation, or complex geometry calculation inside the per-frame content closure.
4. Core Animation through a narrow bridge
Core Animation remains useful, but the reason should be concrete. Good candidates include:
- animating a
CAShapeLayerpath orstrokeEnddirectly - inspecting a presentation-layer value during an interruption
- coordinating layer time with an external media clock
- maintaining an existing UIKit or AppKit component whose layer animation is already correct
- using a specialized rendering view that SwiftUI does not expose
Core Animation does not make all setup work independent of the main thread. Your app still creates layers, changes model-layer values, and commits transactions through its UI pipeline. After commit, the render server can composite eligible properties efficiently. That distinction is why a simple transform may remain smooth while layout, drawing, or per-frame application work still stutters.
A Safe Bridging Pattern
Keep the imperative animation inside a representable and make SwiftUI state the input:
struct RingProgressView: UIViewRepresentable {
let progress: CGFloat
func makeCoordinator() -> Coordinator {
Coordinator()
}
func makeUIView(context: Context) -> UIView {
let view = UIView()
let ring = CAShapeLayer()
ring.fillColor = UIColor.clear.cgColor
ring.strokeColor = UIColor.systemBlue.cgColor
ring.lineWidth = 6
ring.strokeEnd = 0
view.layer.addSublayer(ring)
context.coordinator.ring = ring
return view
}
func updateUIView(_ view: UIView, context: Context) {
guard let ring = context.coordinator.ring else { return }
let previous = ring.presentation()?.strokeEnd ?? ring.strokeEnd
ring.strokeEnd = progress
let animation = CABasicAnimation(keyPath: "strokeEnd")
animation.fromValue = previous
animation.toValue = progress
animation.duration = 0.3
animation.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
ring.add(animation, forKey: "progress")
}
final class Coordinator {
var ring: CAShapeLayer?
}
}
A production component also updates the layer path in layoutSubviews, avoids replaying an identical animation during unrelated SwiftUI updates, and handles initial state without animating from a stale value. The important boundary is architectural: SwiftUI owns the desired progress, while the bridge owns layer mechanics.
Performance: Animate Rendering, Not Work
Animation performance problems usually come from the update chain around the animation rather than the curve itself.
Prefer properties that can be represented as transforms, opacity, clipping, or other inexpensive visual changes. Be cautious when animation changes intrinsic size, text layout, alignment guides, or geometry that invalidates a large hierarchy.
For keyframes, remember that the content closure runs every frame. Keep the animated value small and composed of animatable value types. Resolve images, strings, paths, and business state before the timeline begins.
For continuously changing data such as an audio meter, separate sampling from display. Throttle or aggregate high-rate input to the visual rate the interface actually needs. If the requirement is a dense waveform or particle field, a drawing or rendering API may fit better than hundreds of independently updating SwiftUI views.
Profile the release build on a physical device with Instruments. Check CPU work, hangs, and animation hitches together. A simulator is useful for layout iteration but does not reproduce device thermal behavior, refresh rate, or graphics scheduling reliably.
Interruption and State Ownership
An animation can be visually correct and still be architecturally wrong. The source of truth should describe product state, not animation progress. A save operation owns states such as idle, saving, succeeded, and failed; the view derives motion from those states.
Do not use delayed callbacks as a hidden state machine. Cancellation, rapid repeated input, navigation, and scene changes will expose the mismatch. Trigger-based phase and keyframe APIs are useful because a new value creates an explicit replay boundary.
When precise mid-flight continuity is essential, Core Animation's presentation layer can provide the currently displayed layer value. Treat that as a rendering detail, then update the model layer to the final value so the visual state does not snap back when the animation is removed.
Accessibility Is Part of the Animation Contract
Motion should communicate state without becoming the only way state is communicated. Respect Reduce Motion:
@Environment(\.accessibilityReduceMotion) private var reduceMotion
private var feedbackAnimation: Animation? {
reduceMotion ? .easeOut(duration: 0.12) : .spring(duration: 0.4, bounce: 0.3)
}
For large spatial movement, repeated pulses, parallax, or zoom, provide a restrained alternative. Opacity, color, symbol changes, and immediate state transitions can preserve meaning with less motion. Also test Reduce Transparency, Increase Contrast, Dynamic Type, and VoiceOver focus; an animation should not move the active control away from the user's focus unexpectedly.
Decision Checklist
Use ordinary state animation when:
- one product-state transition drives the effect
- one curve can describe the movement
- interruption should naturally follow state changes
Use PhaseAnimator when:
- the effect has a small, named sequence of discrete states
- each transition benefits from its own animation
- the sequence loops or replays from a trigger
Use KeyframeAnimator when:
- several properties need independent tracks
- timing relationships matter more than named phases
- the per-frame content closure can remain inexpensive
Bridge to Core Animation when:
- the requirement depends on a layer capability SwiftUI does not expose
- you need presentation-layer inspection or explicit layer timing
- an existing UIKit or AppKit rendering component is the correct owner
Production Architecture
Keep reusable motion in small modifiers or dedicated views, but do not build an animation framework before the product has repeated patterns. A named SaveConfirmation, LoadingPulseModifier, or RingProgressView is easier to understand and test than a generic timeline abstraction with configuration dictionaries.
Animation triggers should come from observable feature state. Heavy work belongs in services or actors, not in view bodies or animation callbacks. The production SwiftUI architecture guide covers state ownership and observation boundaries; the iOS performance optimization guide covers the profiling workflow when frames still drop.