swiftui-animation
dpearson2699/swift-ios-skills
Implement and review SwiftUI animations, transitions, and SF Symbol effects with modern iOS 17+ APIs.
What is swiftui-animation?
This skill covers SwiftUI animation implementation using explicit animations (withAnimation), implicit animations (.animation), spring physics, phase/keyframe choreography, hero transitions (matchedGeometryEffect), and SF Symbol effects. Use it when building or reviewing animations that need correct timing, accessibility handling, and Swift 6 patterns.
- Apply explicit animations with withAnimation and completion handlers
- Configure implicit animations with .animation(_:body:) and .animation(_:value:)
- Use spring presets (.smooth, .snappy, .bouncy) and custom Spring physics
- Build multi-phase animations with PhaseAnimator and complex choreography with KeyframeAnimator
- Create hero transitions with matchedGeometryEffect and navigation zoom transitions
- Add SF Symbol effects (bounce, pulse, variableColor, scale, appear, disappear, replace, breathe, rotate, wiggle)
How to install swiftui-animation
npx skills add https://github.com/dpearson2699/swift-ios-skills --skill swiftui-animationHow to use swiftui-animation
- 1.Identify the animation category (state-driven, multi-phase, keyframe, shared element, navigation, or symbol)
- 2.Choose the appropriate animation curve (linear, easeIn/Out, spring presets, or custom)
- 3.Apply the animation using the correct API (withAnimation, .animation modifier, PhaseAnimator, KeyframeAnimator, or .symbolEffect)
- 4.Test with Accessibility > Reduce Motion enabled to verify accessibility compliance
- 5.Verify animations trigger on correct state changes and avoid expensive work in animation closures
Use cases
- Building state-driven UI transitions like expand/collapse or toggle effects
- Sequencing multi-step animations with PhaseAnimator for pulsing or cycling effects
- Choreographing complex multi-property animations with KeyframeAnimator for bouncing or morphing
- Creating shared-element transitions between views with matchedGeometryEffect
- Adding SF Symbol animations to icons for visual feedback
- iOS app developers building interactive UIs
- SwiftUI developers implementing animations and transitions
- Code reviewers checking animation correctness and accessibility
- Developers migrating from Core Animation to modern SwiftUI APIs
swiftui-animation FAQ
Use withAnimation for explicit state mutations where you own the state change. Use .animation(_:value:) for simple value-bound changes on a single property, or .animation(_:body:) to selectively animate specific modifiers.
.smooth has no bounce and is fluid; .snappy has a small bounce and feels responsive; .bouncy has visible bounce and feels playful. Choose based on the desired feel and interaction context.
Use KeyframeAnimator with separate KeyframeTrack blocks for each property. Each track can have its own keyframe types (LinearKeyframe, CubicKeyframe, SpringKeyframe) and durations.
Always test with Accessibility > Reduce Motion enabled. Use withAnimation with completion criteria and verify that critical animations either disable or provide instant alternatives when reduce motion is active.
PhaseAnimator cycles through discrete phases with per-phase animation curves, useful for sequenced multi-step animations. KeyframeAnimator animates multiple properties along independent timelines with precise timing control.
Full instructions (SKILL.md)
Source of truth, from dpearson2699/swift-ios-skills.
name: swiftui-animation description: "Implement, review, or improve SwiftUI animations and transitions. Use when adding explicit animations with withAnimation, configuring implicit animations with .animation(:body:) or .animation(:value:), configuring spring animations (.smooth, .snappy, .bouncy), building phase or keyframe animations with PhaseAnimator/KeyframeAnimator, creating hero transitions with matchedGeometryEffect or matchedTransitionSource, adding SF Symbol effects (iOS 17 bounce, pulse, variableColor, scale, appear, disappear, replace; iOS 18 breathe, rotate, wiggle), implementing custom Transition or CustomAnimation types, or ensuring animations respect accessibilityReduceMotion."
SwiftUI Animation (iOS 26+)
Review, write, and fix SwiftUI animations. Apply modern animation APIs with correct timing, transitions, and accessibility handling using Swift 6.3 patterns.
Contents
- Triage Workflow
- withAnimation (Explicit Animation)
- Implicit Animation
- Spring Type (iOS 17+)
- PhaseAnimator (iOS 17+)
- KeyframeAnimator (iOS 17+)
@Animatable Macro- matchedGeometryEffect (iOS 14+)
- Navigation Zoom Transition (iOS 18+)
- Transitions (iOS 17+)
- ContentTransition (iOS 16+)
- Symbol Effects (iOS 17+)
- Symbol Rendering Modes
- Common Mistakes
- Review Checklist
- References
Triage Workflow
Step 1: Identify the animation category
| Category | API | When to use |
|---|---|---|
| State-driven | withAnimation, .animation(_:body:), .animation(_:value:) | Explicit state changes, selective modifier animation, or simple value-bound changes |
| Multi-phase | PhaseAnimator | Sequenced multi-step animations |
| Keyframe | KeyframeAnimator | Complex multi-property choreography |
| Shared element | matchedGeometryEffect | Layout-driven hero transitions |
| Navigation | matchedTransitionSource + .navigationTransition(.zoom) | NavigationStack push/pop zoom |
| View lifecycle | .transition() | Insertion and removal |
| Text content | .contentTransition() | In-place text/number changes |
| Symbol | .symbolEffect() | SF Symbol animations |
| Custom | CustomAnimation protocol | Novel timing curves |
| Core Animation bridge | CALayer, CAAnimation, CADisplayLink | Read references/core-animation-bridge.md before advising |
Step 2: Choose the animation curve
// Timing curves
.linear // constant speed
.easeIn(duration: 0.3) // slow start
.easeOut(duration: 0.3) // slow end
.easeInOut(duration: 0.3) // slow start and end
.timingCurve(.bezier(startControlPoint: .zero, endControlPoint: .init(x: 1, y: 1)), duration: 0.3)
// Spring presets (preferred for natural motion)
.smooth // no bounce, fluid
.smooth(duration: 0.5, extraBounce: 0.0)
.snappy // small bounce, responsive
.snappy(duration: 0.4, extraBounce: 0.1)
.bouncy // visible bounce, playful
.bouncy(duration: 0.5, extraBounce: 0.2)
// Custom spring
.spring(duration: 0.5, bounce: 0.3, blendDuration: 0.0)
.spring(Spring(duration: 0.6, bounce: 0.2), blendDuration: 0.0)
.interactiveSpring(response: 0.15, dampingFraction: 0.86)
Step 3: Apply and verify
- Confirm animation triggers on the correct state change.
- Test with Accessibility > Reduce Motion enabled.
- Verify no expensive work runs inside animation content closures.
- For CA bridges, use Coordinators for delegates, invalidate display links, treat frame-rate ranges as hints, and adapt work to the actual refresh rate.
withAnimation (Explicit Animation)
withAnimation(.spring) { isExpanded.toggle() }
// With completion (iOS 17+)
withAnimation(.smooth(duration: 0.35), completionCriteria: .logicallyComplete) {
isExpanded = true
} completion: { loadContent() }
Implicit Animation
Use withAnimation for state-mutation ownership, .animation(_:body:) for
selected modifiers, and .animation(_:value:) for simple value-bound changes.
Badge()
.foregroundStyle(isActive ? .green : .secondary)
.animation(.snappy) { content in
content
.scaleEffect(isActive ? 1.15 : 1.0)
.opacity(isActive ? 1.0 : 0.7)
}
Circle()
.scaleEffect(isActive ? 1.2 : 1.0)
.opacity(isActive ? 1.0 : 0.6)
.animation(.bouncy, value: isActive)
Spring Type (iOS 17+)
Four initializer forms for different mental models.
// Perceptual (preferred)
Spring(duration: 0.5, bounce: 0.3)
// Physical
Spring(mass: 1.0, stiffness: 100.0, damping: 10.0)
// Response-based
Spring(response: 0.5, dampingRatio: 0.7)
// Settling-based
Spring(settlingDuration: 1.0, dampingRatio: 0.8)
Three presets mirror Animation presets: .smooth, .snappy, .bouncy.
PhaseAnimator (iOS 17+)
Cycle through discrete phases with per-phase animation curves.
enum PulsePhase: CaseIterable {
case idle, grow, shrink
}
struct PulsingDot: View {
var body: some View {
PhaseAnimator(PulsePhase.allCases) { phase in
Circle()
.frame(width: 40, height: 40)
.scaleEffect(phase == .grow ? 1.4 : 1.0)
.opacity(phase == .shrink ? 0.5 : 1.0)
} animation: { phase in
switch phase {
case .idle: .easeIn(duration: 0.2)
case .grow: .spring(duration: 0.4, bounce: 0.3)
case .shrink: .easeOut(duration: 0.3)
}
}
}
}
Trigger-based variant advances to the next phase on each trigger change:
PhaseAnimator(PulsePhase.allCases, trigger: tapCount) { phase in
// ...
} animation: { _ in .spring(duration: 0.4) }
KeyframeAnimator (iOS 17+)
Animate multiple properties along independent timelines.
struct AnimValues {
var scale: Double = 1.0
var yOffset: Double = 0.0
var opacity: Double = 1.0
}
struct BounceView: View {
@State private var trigger = false
var body: some View {
Button { trigger.toggle() } label: {
Image(systemName: "star.fill")
.font(.largeTitle)
.keyframeAnimator(
initialValue: AnimValues(),
trigger: trigger
) { content, value in
content
.scaleEffect(value.scale)
.offset(y: value.yOffset)
.opacity(value.opacity)
} keyframes: { _ in
KeyframeTrack(\.scale) {
SpringKeyframe(1.5, duration: 0.3)
CubicKeyframe(1.0, duration: 0.4)
}
KeyframeTrack(\.yOffset) {
CubicKeyframe(-30, duration: 0.2)
CubicKeyframe(0, duration: 0.4)
}
KeyframeTrack(\.opacity) {
LinearKeyframe(0.6, duration: 0.15)
LinearKeyframe(1.0, duration: 0.25)
}
}
}
.buttonStyle(.plain)
}
}
Keyframe types: LinearKeyframe (linear), CubicKeyframe (smooth curve),
SpringKeyframe (spring physics), MoveKeyframe (instant jump).
Use repeating: true for looping keyframe animations.
Swift 6: keyframe closures are @Sendable; capture state/env values before the modifier.
@Animatable Macro
Replaces manual AnimatableData boilerplate. Attach to any type with
animatable stored properties.
@Animatable
struct WaveShape: Shape {
var frequency: Double
var amplitude: Double
var phase: Double
@AnimatableIgnored var lineWidth: CGFloat
func path(in rect: CGRect) -> Path {
// draw wave using frequency, amplitude, phase
}
}
Rules:
- Stored properties must conform to
VectorArithmetic. - Use
@AnimatableIgnoredto exclude non-animatable properties. - Computed properties are never included.
matchedGeometryEffect (iOS 14+)
Synchronize geometry between views for shared-element animations.
struct HeroView: View {
@Namespace private var heroSpace
@State private var isExpanded = false
var body: some View {
Group {
if isExpanded {
Button {
withAnimation(.spring(duration: 0.4, bounce: 0.2)) {
isExpanded = false
}
} label: {
DetailCard()
.matchedGeometryEffect(id: "card", in: heroSpace)
}
} else {
Button {
withAnimation(.spring(duration: 0.4, bounce: 0.2)) {
isExpanded = true
}
} label: {
ThumbnailCard()
.matchedGeometryEffect(id: "card", in: heroSpace)
}
}
}
.buttonStyle(.plain)
}
}
Exactly one source view per ID should be visible; otherwise results are undefined.
Navigation Zoom Transition (iOS 18+)
Pair matchedTransitionSource on the source view with
.navigationTransition(.zoom(...)) on the destination.
struct GalleryView: View {
@Namespace private var zoomSpace
let items: [GalleryItem]
var body: some View {
NavigationStack {
ScrollView {
LazyVGrid(columns: [GridItem(.adaptive(minimum: 100))]) {
ForEach(items) { item in
NavigationLink {
GalleryDetail(item: item)
.navigationTransition(
.zoom(sourceID: item.id, in: zoomSpace)
)
} label: {
ItemThumbnail(item: item)
.matchedTransitionSource(
id: item.id, in: zoomSpace
)
}
}
}
}
}
}
}
Apply .navigationTransition on the destination view, not on inner containers.
Transitions (iOS 17+)
Control how views animate on insertion and removal.
if showBanner {
BannerView()
.transition(.move(edge: .top).combined(with: .opacity))
}
Built-in types: .opacity, .slide, .scale, .scale(_:anchor:),
.move(edge:), .push(from:), .offset(x:y:), .identity,
.blurReplace, .blurReplace(_:), .symbolEffect,
.symbolEffect(_:options:).
Asymmetric transitions:
.transition(.asymmetric(
insertion: .push(from: .bottom),
removal: .opacity
))
ContentTransition (iOS 16+)
Animate in-place content changes without insertion/removal.
Text("\(score)")
.contentTransition(.numericText(countsDown: false))
.animation(.snappy, value: score)
// For SF Symbols
Image(systemName: isMuted ? "speaker.slash" : "speaker.wave.3")
.contentTransition(.symbolEffect(.replace.downUp))
Types: .identity, .interpolate, .opacity,
.numericText(countsDown:), .numericText(value:), .symbolEffect.
Symbol Effects (iOS 17+)
Animate SF Symbols with semantic effects. .bounce, .pulse, .variableColor,
.scale, .appear, .disappear, and .replace are iOS 17+; .breathe,
.rotate, and .wiggle require iOS 18+.
// Discrete (triggers on value change)
Image(systemName: "bell.fill").symbolEffect(.bounce, value: notificationCount)
// iOS 18+
Image(systemName: "arrow.clockwise")
.symbolEffect(.wiggle.clockwise, value: refreshCount)
// Indefinite (active while condition holds)
Image(systemName: "wifi").symbolEffect(.pulse, isActive: isSearching)
// iOS 18+
Image(systemName: "mic.fill")
.symbolEffect(.breathe, isActive: isRecording)
// Variable color with chaining
Image(systemName: "speaker.wave.3.fill")
.symbolEffect(
.variableColor.iterative.reversing.dimInactiveLayers,
options: .repeating,
isActive: isPlaying
)
Availability: iOS 17+ for .bounce, .pulse, .variableColor, .scale,
.appear, .disappear, .replace; iOS 18+ for .breathe, .rotate,
.wiggle.
Scope: .byLayer, .wholeSymbol. Direction varies per effect.
Symbol Rendering Modes
Control how SF Symbol layers are colored with .symbolRenderingMode(_:).
| Mode | Effect | When to use |
|---|---|---|
.monochrome | Single color applied uniformly (default) | Toolbars, simple icons matching text |
.hierarchical | Single color with opacity layers for depth | Subtle depth without multiple colors |
.multicolor | System-defined fixed colors per layer | Weather, file types — Apple's intended palette |
.palette | Custom colors per layer via .foregroundStyle | Brand colors, custom multi-color icons |
// Hierarchical — single tint, opacity layers for depth
Image(systemName: "speaker.wave.3.fill")
.symbolRenderingMode(.hierarchical)
.foregroundStyle(.blue)
// Palette — custom color per layer
Image(systemName: "person.crop.circle.badge.plus")
.symbolRenderingMode(.palette)
.foregroundStyle(.blue, .green)
// Multicolor — system-defined colors
Image(systemName: "cloud.sun.rain.fill")
.symbolRenderingMode(.multicolor)
Variable symbols: use Image(systemName:variableValue:) (iOS 16+) for percentage fill. Use .symbolVariableValueMode(_:) (iOS 26+) to choose .draw or .color.
Image(systemName: "wifi", variableValue: signalStrength) // 0.0...1.0
.symbolVariableValueMode(.draw) // iOS 26+
Docs: SymbolRenderingMode · symbolRenderingMode(_:) · Image(systemName:variableValue:) · symbolVariableValueMode(_:)
Common Mistakes
1. Using bare .animation(_:) when you need precise scope
// TOO BROAD — applies when the view changes
.animation(.easeIn)
.animation(.easeIn, value: isVisible) // CORRECT: value-bound
// CORRECT — scope animation to selected modifiers
.animation(.easeIn) { content in
content.opacity(isVisible ? 1.0 : 0.0)
}
withAnimation(.easeIn) { isVisible.toggle() } // CORRECT: own mutation
2. Expensive work or actor-isolated reads inside animation closures
keyframeAnimator / PhaseAnimator content closures run every frame. Precompute expensive values, animate only visual properties, and capture state/env values before @Sendable keyframe closures.
3. Missing reduce motion support
For symbols, remove inherited effects; gate larger motion with reduceMotion ? .none : animation.
@Environment(\.accessibilityReduceMotion) private var reduceMotion
Image(systemName: "wifi").symbolEffect(.pulse, isActive: isSearching).symbolEffectsRemoved(reduceMotion)
4. Multiple matchedGeometryEffect sources
Only one source view per ID should be visible at a time. Multiple visible sources with the same ID cause undefined layout.
5. Using DispatchQueue or UIView.animate
// WRONG
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { withAnimation { isVisible = true } }
// CORRECT
withAnimation(.spring.delay(0.5)) { isVisible = true }
6. Forgetting animation on ContentTransition
// WRONG — no animation, content transition has no effect
Text("\(count)").contentTransition(.numericText(countsDown: true))
// CORRECT — pair with animation
Text("\(count)")
.contentTransition(.numericText(countsDown: true))
.animation(.snappy, value: count)
7. navigationTransition on wrong view
Apply .navigationTransition(.zoom(sourceID:in:)) on the outermost destination view, not inside a container.
Review Checklist
- Animation curve matches intent (spring for natural, ease for mechanical)
-
withAnimationwraps the state change; implicit animation uses.animation(_:body:)for selective modifier scope or.animation(_:value:)with an explicit value -
matchedGeometryEffecthas exactly one source per ID; zoom uses matchingid/namespace -
@Animatablemacro used when synthesis fits; manualanimatableDatakept only when custom packing is clearer -
accessibilityReduceMotionchecked; noDispatchQueue/UIView.animate - Transitions use
.transition();contentTransitionis paired with animation and uses the narrowest implicit animation scope that fits - Animated state changes on @MainActor; animation-driving types are Sendable
References
- See references/animation-advanced.md for CustomAnimation protocol, Spring variants, Transition types, symbol effects, Transaction system, UnitCurve, and performance guidance; Core Animation bridging patterns: references/core-animation-bridge.md.
Related skills
More from dpearson2699/swift-ios-skills and the wider catalog.

swiftui-gestures
Implement and compose SwiftUI gestures with modern APIs, state management, and conflict resolution.

swiftui-layout-components
Build SwiftUI layouts with stacks, grids, lists, forms, and controls for iOS 17+.

swiftui-liquid-glass
Implement SwiftUI Liquid Glass effects for iOS 26+ with glass buttons, morphing transitions, and interactive controls.

swiftui-navigation
SwiftUI navigation patterns: NavigationStack, NavigationSplitView, sheets, tabs, and deep linking for iOS 17+.

swiftui-patterns
Build and review SwiftUI views with modern MV architecture, state management, and composition patterns.

swiftui-performance
Audit and optimize SwiftUI runtime performance—diagnose slow rendering, janky scrolling, and excessive view updates.