Why Animations Matter More Than You Think
Animations are not decoration. They are a critical part of how users understand your app. When a card slides in from the right, the user knows it came from somewhere and can swipe it back. When a button shrinks on tap, the user gets instant feedback that the touch registered. When a deleted row fades out, the user does not wonder where the data went. Remove those animations and your app feels broken, even when it is technically working.
Apple's Human Interface Guidelines emphasize this point directly: motion should communicate status, provide feedback, and help users visualize the results of their actions. In App Store editorial features, polished animations are consistently cited as a differentiator. The apps that get featured look and feel alive.
SwiftUI makes animation dramatically easier than UIKit ever did. In UIKit you would write a dozen lines inside UIView.animate(withDuration:) blocks, manage completion handlers, and coordinate multiple property changes manually. In SwiftUI, most animations are a single modifier or a single closure. But easy does not mean simple. There are at least six distinct animation APIs in SwiftUI, each designed for a different use case, and choosing the wrong one creates jank, visual glitches, or wasted CPU cycles.
This guide covers every major animation API in SwiftUI, from the basics through iOS 17's newest additions. Every section includes production-ready code you can copy directly into your project.
Implicit vs. Explicit Animations
SwiftUI has two fundamental approaches to animation, and understanding the difference is the single most important concept in this entire guide.
Implicit Animations with .animation()
An implicit animation watches a specific value and automatically animates any view changes that result from that value changing. You attach it with the .animation(_:value:) modifier:
struct ImplicitExample: View {
@State private var isExpanded = false
var body: some View {
VStack(spacing: 20) {
RoundedRectangle(cornerRadius: 16)
.fill(.blue.gradient)
.frame(
width: isExpanded ? 300 : 150,
height: isExpanded ? 200 : 100
)
.animation(.spring(duration: 0.5, bounce: 0.3), value: isExpanded)
Button("Toggle") {
isExpanded.toggle()
}
}
}
}Here, .animation(.spring(...), value: isExpanded) tells SwiftUI: whenever isExpanded changes, animate the frame change on this rectangle with a spring curve. The animation only fires when that specific value changes — not when other state changes. This is intentional and prevents accidental animations on unrelated updates.
Important: Always use thevalue:parameter. The deprecated.animation(.spring())form (without a value) animates every state change, which causes unpredictable behavior in complex views. Apple deprecated it in iOS 15 for good reason.
Explicit Animations with withAnimation
An explicit animation wraps a state change in a withAnimation closure. Every view that depends on the changed state will animate:
struct ExplicitExample: View {
@State private var isExpanded = false
@State private var rotation: Double = 0
var body: some View {
VStack(spacing: 20) {
RoundedRectangle(cornerRadius: 16)
.fill(.purple.gradient)
.frame(
width: isExpanded ? 300 : 150,
height: isExpanded ? 200 : 100
)
.rotationEffect(.degrees(rotation))
Button("Animate") {
withAnimation(.spring(duration: 0.6, bounce: 0.25)) {
isExpanded.toggle()
rotation += 90
}
}
}
}
}With explicit animations, you control exactly when the animation happens by wrapping the state mutation. This is ideal when you need to animate multiple properties in sync — here, the frame and the rotation animate together with the same spring curve.
When to Use Each
- Implicit (.animation) — best for isolated, self-contained view animations. A pulsing indicator, a color shift on hover, a single element expanding. The animation is tied to the view, not to the trigger.
- Explicit (withAnimation) — best for coordinated state changes that affect multiple views. A navigation transition, a layout shift with multiple elements, toggling a mode that changes the entire screen.
In practice, most production apps use explicit animations for the majority of their interactions. Implicit animations shine for smaller, localized effects like loading indicators or hover feedback.
Animation Types and Timing Curves
SwiftUI ships with several built-in animation types. Each one defines how a value changes over time — the timing curve. Choosing the right curve is the difference between motion that feels natural and motion that feels robotic.
| Animation Type | Syntax | Behavior | Best For |
|---|---|---|---|
| .linear | .linear(duration: 0.3) | Constant speed from start to finish | Progress bars, spinning loaders, color cycling |
| .easeIn | .easeIn(duration: 0.3) | Starts slow, accelerates | Elements leaving the screen, fade-outs |
| .easeOut | .easeOut(duration: 0.3) | Starts fast, decelerates | Elements entering the screen, drop-ins |
| .easeInOut | .easeInOut(duration: 0.3) | Slow start, fast middle, slow end | General-purpose, default if you are unsure |
| .spring | .spring(duration: 0.5, bounce: 0.3) | Physics-based spring with optional overshoot | Most UI interactions, buttons, cards, toggles |
| .smooth | .smooth(duration: 0.4) | Spring with zero bounce (critically damped) | Subtle movements, layout changes, sheets |
| .snappy | .snappy(duration: 0.3) | Fast spring with minimal bounce | Quick feedback, tab switches, icon changes |
| .bouncy | .bouncy(duration: 0.5) | Spring with noticeable bounce | Playful UI, game elements, celebratory effects |
| .interpolatingSpring | .interpolatingSpring(stiffness: 200, damping: 15) | Fine-tuned spring with physics parameters | Custom feel, brand-specific motion |
| Custom Bezier | .timingCurve(0.2, 0.8, 0.2, 1, duration: 0.4) | Custom cubic bezier curve | Matching design tools (Figma, After Effects) |
A practical rule of thumb: use spring animations for almost everything. They feel more natural than bezier curves because they model real-world physics. Apple's own iOS animations are predominantly spring-based. The .spring(duration: 0.5, bounce: 0.2) preset is an excellent starting point for most interactions.
// Custom spring with fine-tuned physics
withAnimation(.interpolatingSpring(
stiffness: 180,
damping: 14,
initialVelocity: 0.5
)) {
cardOffset = .zero
}
// Custom bezier matching a Figma prototype
withAnimation(.timingCurve(0.25, 0.1, 0.25, 1.0, duration: 0.35)) {
showPanel = true
}Built-In Transitions
Transitions control how views appear and disappear. When a view is conditionally inserted or removed from the hierarchy (via if statements or ternary expressions inside the view body), SwiftUI uses the .transition() modifier to animate the insertion and removal.
| Transition | Syntax | Effect | Common Use Case |
|---|---|---|---|
| .opacity | .transition(.opacity) | Fades in/out | Overlays, toasts, error messages |
| .slide | .transition(.slide) | Slides from leading edge, exits to trailing | Side panels, navigation drawers |
| .scale | .transition(.scale) | Scales from zero to full size | Popups, FAB menus, modal cards |
| .move(edge:) | .transition(.move(edge: .bottom)) | Slides in from the specified edge | Bottom sheets, banners, notifications |
| .push(from:) | .transition(.push(from: .trailing)) | Pushes in from one edge, pushes out from opposite | Step-by-step wizards, onboarding flows |
| .offset | .transition(.offset(y: 50)) | Appears from a specific offset position | List items, staggered entry animations |
| .asymmetric | .transition(.asymmetric(insertion: .scale, removal: .opacity)) | Different animation for insert vs. remove | Cards that pop in but fade out, complex flows |
| .combined | .transition(.scale.combined(with: .opacity)) | Combines multiple transitions | Rich entry effects, polished micro-interactions |
Here is a practical example using .asymmetric for a notification banner that slides down from the top but fades out when dismissed:
struct NotificationBanner: View {
@State private var showBanner = false
var body: some View {
ZStack(alignment: .top) {
Color.clear // Background
if showBanner {
HStack(spacing: 12) {
Image(systemName: "checkmark.circle.fill")
.foregroundStyle(.green)
Text("Purchase successful!")
.font(.subheadline.weight(.medium))
}
.padding()
.frame(maxWidth: .infinity)
.background(.ultraThinMaterial)
.clipShape(RoundedRectangle(cornerRadius: 16))
.padding(.horizontal)
.transition(.asymmetric(
insertion: .move(edge: .top).combined(with: .opacity),
removal: .opacity
))
}
Button(showBanner ? "Dismiss" : "Show Banner") {
withAnimation(.spring(duration: 0.5, bounce: 0.2)) {
showBanner.toggle()
}
}
.padding(.top, 100)
}
}
}matchedGeometryEffect for Hero Transitions
The matchedGeometryEffect modifier is one of SwiftUI's most powerful animation tools. It creates smooth hero transitions between two completely separate views by matching their geometry — position, size, and shape — across an animated state change.
The concept is simple: you give two views the same id within a shared @Namespace. When one view disappears and the other appears, SwiftUI automatically interpolates between their frames, creating a fluid morphing effect.
struct HeroTransitionDemo: View {
@Namespace private var heroNamespace
@State private var selectedCard: Int? = nil
var body: some View {
ZStack {
// Grid of cards
if selectedCard == nil {
LazyVGrid(columns: [
GridItem(.flexible()),
GridItem(.flexible())
], spacing: 16) {
ForEach(0..<4) { index in
cardView(index: index)
.matchedGeometryEffect(
id: "card-\(index)",
in: heroNamespace
)
.onTapGesture {
withAnimation(.spring(duration: 0.5, bounce: 0.2)) {
selectedCard = index
}
}
}
}
.padding()
}
// Expanded card detail
if let selected = selectedCard {
expandedCardView(index: selected)
.matchedGeometryEffect(
id: "card-\(selected)",
in: heroNamespace
)
.onTapGesture {
withAnimation(.spring(duration: 0.5, bounce: 0.2)) {
selectedCard = nil
}
}
.transition(.opacity)
}
}
}
func cardView(index: Int) -> some View {
RoundedRectangle(cornerRadius: 20)
.fill(
[Color.blue, .purple, .orange, .pink][index]
.gradient
)
.frame(height: 150)
.overlay(
Text("Card \(index + 1)")
.font(.headline)
.foregroundStyle(.white)
)
}
func expandedCardView(index: Int) -> some View {
RoundedRectangle(cornerRadius: 24)
.fill(
[Color.blue, .purple, .orange, .pink][index]
.gradient
)
.frame(maxWidth: .infinity, maxHeight: 400)
.overlay(
VStack(spacing: 12) {
Text("Card \(index + 1)")
.font(.largeTitle.bold())
Text("Tap to collapse")
.font(.subheadline)
}
.foregroundStyle(.white)
)
.padding()
}
}This is the foundation for App Store-style card expansions, photo gallery zoom-ins, and any transition where an element appears to morph from a thumbnail into a full-screen view. The key to making it smooth is ensuring both views share the same id and @Namespace, and wrapping the state change in withAnimation.
Performance tip: matchedGeometryEffect works best with simple shapes. If you match complex views with many subviews, SwiftUI may struggle to interpolate smoothly. Keep the matched element itself simple (a shape or image) and layer additional content on top with separate transitions.PhaseAnimator (iOS 17+)
PhaseAnimator is Apple's answer to multi-step animations. Before iOS 17, sequencing animations required nesting withAnimation calls with DispatchQueue.main.asyncAfter delays — fragile and hard to maintain. PhaseAnimator defines a sequence of phases and automatically steps through them.
// A pulsing notification dot that cycles through phases
struct PulsingDot: View {
var body: some View {
PhaseAnimator([false, true]) { phase in
Circle()
.fill(.red)
.frame(width: phase ? 14 : 10, height: phase ? 14 : 10)
.opacity(phase ? 1.0 : 0.6)
.scaleEffect(phase ? 1.2 : 1.0)
} animation: { phase in
phase ? .easeInOut(duration: 0.8) : .easeInOut(duration: 0.6)
}
}
}
// Multi-step loading animation
enum LoadingPhase: CaseIterable {
case idle, scale, rotate, settle
}
struct AnimatedLoader: View {
var body: some View {
PhaseAnimator(LoadingPhase.allCases) { phase in
Image(systemName: "arrow.trianglehead.2.clockwise")
.font(.system(size: 32, weight: .medium))
.rotationEffect(.degrees(phase == .rotate ? 360 : 0))
.scaleEffect(phase == .scale ? 1.3 : 1.0)
.opacity(phase == .idle ? 0.5 : 1.0)
.foregroundStyle(.blue.gradient)
} animation: { phase in
switch phase {
case .idle: .smooth(duration: 0.3)
case .scale: .spring(duration: 0.4, bounce: 0.4)
case .rotate: .linear(duration: 0.6)
case .settle: .smooth(duration: 0.3)
}
}
}
}PhaseAnimator automatically loops through your phases. You can also use the trigger: parameter to start the animation sequence only when a specific value changes, rather than looping continuously:
// Triggered animation — plays once per state change
struct CelebrationBadge: View {
@State private var triggerCount = 0
var body: some View {
VStack(spacing: 20) {
PhaseAnimator(
[false, true],
trigger: triggerCount
) { phase in
Image(systemName: "star.fill")
.font(.system(size: 48))
.foregroundStyle(.yellow)
.scaleEffect(phase ? 1.5 : 1.0)
.rotationEffect(.degrees(phase ? 15 : 0))
} animation: { phase in
phase
? .spring(duration: 0.3, bounce: 0.5)
: .spring(duration: 0.4)
}
Button("Celebrate") {
triggerCount += 1
}
}
}
}KeyframeAnimator (iOS 17+)
While PhaseAnimator moves through discrete states, KeyframeAnimator gives you timeline-level control. You define keyframes for individual properties, each with their own timing, and SwiftUI interpolates between them. This is the closest SwiftUI gets to After Effects-style motion design.
struct BouncingEntrance: View {
@State private var trigger = false
var body: some View {
VStack(spacing: 30) {
Text("Welcome!")
.font(.largeTitle.bold())
.keyframeAnimator(
initialValue: AnimationValues(),
trigger: trigger
) { content, value in
content
.scaleEffect(value.scale)
.offset(y: value.yOffset)
.opacity(value.opacity)
} keyframes: { _ in
KeyframeTrack(\.scale) {
SpringKeyframe(1.2, duration: 0.3, spring: .bouncy)
SpringKeyframe(0.9, duration: 0.2, spring: .bouncy)
SpringKeyframe(1.0, duration: 0.3, spring: .smooth)
}
KeyframeTrack(\.yOffset) {
LinearKeyframe(-30, duration: 0.15)
SpringKeyframe(0, duration: 0.5, spring: .bouncy)
}
KeyframeTrack(\.opacity) {
LinearKeyframe(1.0, duration: 0.2)
}
}
Button("Replay") {
trigger.toggle()
}
}
}
}
struct AnimationValues {
var scale: Double = 0.5
var yOffset: Double = 50
var opacity: Double = 0
}The AnimationValues struct holds every property you want to animate. Each KeyframeTrack controls one property independently with its own timeline of keyframes. SwiftUI supports LinearKeyframe, SpringKeyframe, CubicKeyframe, and MoveKeyframe for different interpolation styles within each track.
KeyframeAnimator is ideal for complex entrance animations, celebration effects, and any motion that needs precise timing across multiple properties simultaneously.
Custom Transitions
The built-in transitions cover common cases, but sometimes you need something unique to your brand. SwiftUI lets you create fully custom transitions by conforming to the Transition protocol:
// A custom iris/circle-reveal transition
struct IrisTransition: Transition {
func body(content: Content, phase: TransitionPhase) -> some View {
content
.clipShape(Circle())
.scaleEffect(phase.isIdentity ? 1 : 0.01)
.opacity(phase.isIdentity ? 1 : 0)
}
}
extension AnyTransition {
static var iris: AnyTransition {
.modifier(
active: IrisModifier(progress: 0),
identity: IrisModifier(progress: 1)
)
}
}
struct IrisModifier: ViewModifier {
var progress: CGFloat
var animatableData: CGFloat {
get { progress }
set { progress = newValue }
}
func body(content: Content) -> some View {
content
.clipShape(Circle())
.scaleEffect(progress)
.opacity(progress)
}
}
// Usage
struct IrisDemo: View {
@State private var showImage = false
var body: some View {
VStack {
if showImage {
Image(systemName: "photo.fill")
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 200, height: 200)
.transition(.iris)
}
Button("Toggle") {
withAnimation(.spring(duration: 0.6)) {
showImage.toggle()
}
}
}
}
}With the Transition protocol (iOS 17+), the phase parameter tells you whether the view is entering, at identity, or leaving. For earlier iOS versions, use AnyTransition.modifier(active:identity:) with an animatable ViewModifier, as shown above with IrisModifier.
Gesture-Driven Animations
The most polished apps connect animations directly to user gestures, creating a feeling of direct manipulation. In SwiftUI, this means combining DragGesture, MagnificationGesture, or RotationGesture with animated state changes.
struct SwipeableCard: View {
@State private var offset: CGSize = .zero
@State private var isDragging = false
var body: some View {
RoundedRectangle(cornerRadius: 20)
.fill(.blue.gradient)
.frame(width: 300, height: 400)
.overlay(
VStack {
Text("Swipe Me")
.font(.title2.bold())
.foregroundStyle(.white)
Text("Drag left or right")
.foregroundStyle(.white.opacity(0.7))
}
)
.offset(offset)
.rotationEffect(
.degrees(Double(offset.width) / 20)
)
.scaleEffect(isDragging ? 1.05 : 1.0)
.gesture(
DragGesture()
.onChanged { value in
offset = value.translation
isDragging = true
}
.onEnded { value in
isDragging = false
let swipeThreshold: CGFloat = 150
if abs(value.translation.width) > swipeThreshold {
// Swipe away
withAnimation(.easeIn(duration: 0.2)) {
offset = CGSize(
width: value.translation.width > 0 ? 500 : -500,
height: 0
)
}
} else {
// Snap back
withAnimation(.spring(duration: 0.5, bounce: 0.3)) {
offset = .zero
}
}
}
)
.animation(.spring(duration: 0.3), value: isDragging)
}
}This pattern is the basis for Tinder-style card swiping, dismissible sheets, and drag-to-reorder interfaces. The key details: during the drag, the offset tracks the finger with no animation (instant feedback). On release, a spring animation either snaps the card back or flings it off screen. The subtle rotation based on horizontal offset adds polish.
AnimatableModifier for Custom Animatable Data
Sometimes you need to animate a value that SwiftUI does not know how to interpolate by default. The Animatable protocol and animatableData property let you teach SwiftUI how to animate any numeric value.
// Animated counter that smoothly rolls between numbers
struct AnimatedCounter: View, Animatable {
var value: Double
var animatableData: Double {
get { value }
set { value = newValue }
}
var body: some View {
Text("\(Int(value))")
.font(.system(size: 48, weight: .bold, design: .rounded))
.foregroundStyle(.white)
.contentTransition(.numericText())
}
}
// Usage
struct CounterDemo: View {
@State private var count: Double = 0
var body: some View {
VStack(spacing: 20) {
AnimatedCounter(value: count)
Button("Add 100") {
withAnimation(.spring(duration: 0.8)) {
count += 100
}
}
}
}
}
// Animated progress ring using animatableData
struct ProgressRing: Shape {
var progress: Double
var animatableData: Double {
get { progress }
set { progress = newValue }
}
func path(in rect: CGRect) -> Path {
var path = Path()
let center = CGPoint(x: rect.midX, y: rect.midY)
let radius = min(rect.width, rect.height) / 2
let startAngle = Angle(degrees: -90)
let endAngle = Angle(degrees: -90 + (360 * progress))
path.addArc(
center: center,
radius: radius,
startAngle: startAngle,
endAngle: endAngle,
clockwise: false
)
return path
}
}
// Usage
struct RingDemo: View {
@State private var progress: Double = 0
var body: some View {
ProgressRing(progress: progress)
.stroke(.blue.gradient, style: StrokeStyle(
lineWidth: 8,
lineCap: .round
))
.frame(width: 100, height: 100)
.onAppear {
withAnimation(.easeInOut(duration: 1.5)) {
progress = 0.75
}
}
}
}The animatableData property is the bridge between SwiftUI's animation system and your custom values. As long as the type conforms to VectorArithmetic (which Double, CGFloat, and AnimatablePair already do), SwiftUI can interpolate it smoothly over time.
Practical Example: Animated Onboarding
Let us build a real onboarding screen with staggered entrance animations. Each element enters with a delay, creating a cascading reveal effect:
struct AnimatedOnboarding: View {
@State private var showContent = false
@State private var currentPage = 0
let pages = [
OnboardingPage(
icon: "wand.and.stars",
title: "AI-Powered",
subtitle: "Smart features that learn from you"
),
OnboardingPage(
icon: "lock.shield.fill",
title: "Private & Secure",
subtitle: "Your data stays on your device"
),
OnboardingPage(
icon: "bolt.fill",
title: "Lightning Fast",
subtitle: "Optimized for performance"
),
]
var body: some View {
VStack(spacing: 40) {
Spacer()
// Icon with bounce entrance
Image(systemName: pages[currentPage].icon)
.font(.system(size: 72))
.foregroundStyle(.blue.gradient)
.scaleEffect(showContent ? 1 : 0.3)
.opacity(showContent ? 1 : 0)
// Title with slide-up
VStack(spacing: 12) {
Text(pages[currentPage].title)
.font(.largeTitle.bold())
.offset(y: showContent ? 0 : 30)
.opacity(showContent ? 1 : 0)
Text(pages[currentPage].subtitle)
.font(.title3)
.foregroundStyle(.secondary)
.offset(y: showContent ? 0 : 20)
.opacity(showContent ? 1 : 0)
}
Spacer()
// Page dots
HStack(spacing: 8) {
ForEach(0..<pages.count, id: \.self) { index in
Circle()
.fill(index == currentPage ? .blue : .gray.opacity(0.3))
.frame(width: index == currentPage ? 24 : 8, height: 8)
.clipShape(Capsule())
.animation(.spring(duration: 0.4), value: currentPage)
}
}
// Continue button
Button {
withAnimation(.smooth(duration: 0.3)) {
showContent = false
}
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
currentPage = (currentPage + 1) % pages.count
withAnimation(.spring(duration: 0.6, bounce: 0.3)) {
showContent = true
}
}
} label: {
Text("Continue")
.font(.headline)
.frame(maxWidth: .infinity)
.padding(.vertical, 16)
.background(.blue.gradient)
.foregroundStyle(.white)
.clipShape(RoundedRectangle(cornerRadius: 16))
}
.padding(.horizontal, 32)
.padding(.bottom, 40)
}
.onAppear {
withAnimation(.spring(duration: 0.8, bounce: 0.3)) {
showContent = true
}
}
}
}
struct OnboardingPage {
let icon: String
let title: String
let subtitle: String
}Practical Example: Loading Shimmer Effect
Skeleton loading screens with a shimmer effect tell users that content is loading. Here is a reusable shimmer modifier:
// Reusable shimmer modifier
struct ShimmerModifier: ViewModifier {
@State private var phase: CGFloat = 0
func body(content: Content) -> some View {
content
.overlay(
LinearGradient(
colors: [
.clear,
.white.opacity(0.2),
.clear
],
startPoint: .leading,
endPoint: .trailing
)
.offset(x: phase)
.mask(content)
)
.onAppear {
withAnimation(
.linear(duration: 1.5)
.repeatForever(autoreverses: false)
) {
phase = 350
}
}
}
}
extension View {
func shimmer() -> some View {
modifier(ShimmerModifier())
}
}
// Usage: Skeleton loading screen
struct SkeletonLoadingView: View {
var body: some View {
VStack(alignment: .leading, spacing: 16) {
// Avatar + name row
HStack(spacing: 12) {
Circle()
.fill(Color.gray.opacity(0.3))
.frame(width: 48, height: 48)
VStack(alignment: .leading, spacing: 6) {
RoundedRectangle(cornerRadius: 4)
.fill(Color.gray.opacity(0.3))
.frame(width: 120, height: 14)
RoundedRectangle(cornerRadius: 4)
.fill(Color.gray.opacity(0.3))
.frame(width: 80, height: 12)
}
}
// Content lines
ForEach(0..<3, id: \.self) { _ in
RoundedRectangle(cornerRadius: 4)
.fill(Color.gray.opacity(0.3))
.frame(height: 14)
}
// Image placeholder
RoundedRectangle(cornerRadius: 12)
.fill(Color.gray.opacity(0.3))
.frame(height: 200)
}
.shimmer()
.padding()
}
}Practical Example: Button Feedback Animation
Micro-interactions on buttons give users instant feedback. Here is a production-quality button with scale, haptic feedback, and a success state:
struct AnimatedButton: View {
@State private var isPressed = false
@State private var isSuccess = false
var body: some View {
Button {
// Trigger success animation
withAnimation(.spring(duration: 0.3, bounce: 0.4)) {
isSuccess = true
}
// Haptic feedback
let impact = UIImpactFeedbackGenerator(style: .medium)
impact.impactOccurred()
// Reset after delay
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) {
withAnimation(.smooth(duration: 0.3)) {
isSuccess = false
}
}
} label: {
HStack(spacing: 8) {
if isSuccess {
Image(systemName: "checkmark")
.transition(.scale.combined(with: .opacity))
}
Text(isSuccess ? "Done!" : "Submit")
.contentTransition(.numericText())
}
.font(.headline)
.frame(maxWidth: .infinity)
.padding(.vertical, 16)
.background(isSuccess ? .green.gradient : .blue.gradient)
.foregroundStyle(.white)
.clipShape(RoundedRectangle(cornerRadius: 16))
.scaleEffect(isPressed ? 0.95 : 1.0)
}
.buttonStyle(.plain)
.padding(.horizontal, 32)
._onButtonGesture { pressing in
withAnimation(.spring(duration: 0.2)) {
isPressed = pressing
}
} perform: {}
}
}Practical Example: Card Flip Animation
A 3D card flip is a classic animation pattern for flash cards, reveal interactions, or settings toggles:
struct FlipCard: View {
@State private var isFlipped = false
@State private var rotation: Double = 0
var body: some View {
ZStack {
// Front
RoundedRectangle(cornerRadius: 20)
.fill(.blue.gradient)
.overlay(
VStack(spacing: 8) {
Image(systemName: "questionmark")
.font(.system(size: 48, weight: .bold))
Text("Tap to reveal")
.font(.headline)
}
.foregroundStyle(.white)
)
.opacity(isFlipped ? 0 : 1)
// Back
RoundedRectangle(cornerRadius: 20)
.fill(.purple.gradient)
.overlay(
VStack(spacing: 8) {
Image(systemName: "star.fill")
.font(.system(size: 48, weight: .bold))
Text("SwiftUI is amazing!")
.font(.headline)
}
.foregroundStyle(.white)
)
.opacity(isFlipped ? 1 : 0)
.scaleEffect(x: -1) // Mirror text so it reads correctly
}
.frame(width: 280, height: 380)
.rotation3DEffect(
.degrees(rotation),
axis: (x: 0, y: 1, z: 0),
perspective: 0.5
)
.onTapGesture {
withAnimation(.spring(duration: 0.6, bounce: 0.2)) {
rotation += 180
isFlipped.toggle()
}
}
.shadow(
color: .black.opacity(0.2),
radius: 16, x: 0, y: 8
)
}
}Combining Animations
Real-world animations rarely use a single technique. The most polished effects combine multiple approaches. Here are the key combination patterns:
- Staggered delays: Use
.animation(.spring().delay(Double(index) * 0.05), value: trigger)on list items to create a cascading wave effect when content loads. - Chained withAnimation blocks: Nest
DispatchQueue.main.asyncAftercalls with differentwithAnimationcurves to sequence multi-step animations (exit, update, enter). - Matched geometry + transitions: Pair
matchedGeometryEffectfor the main element with separate.transition(.opacity)modifiers for supporting elements like text and buttons. - Gesture + spring: Track the gesture directly during interaction (
.onChanged), then apply a spring on release (.onEnded) for that elastic snap-back feel. - PhaseAnimator for looping + KeyframeAnimator for triggered: Use PhaseAnimator for ambient background animations (pulsing, breathing) and KeyframeAnimator for one-shot triggered effects (success, error, celebration).
Performance Considerations
Animations that look great but drop frames are worse than no animation at all. Here are the rules for keeping your animations at a smooth 60fps (or 120fps on ProMotion devices):
- Animate transforms, not layout. Properties like
.scaleEffect,.rotationEffect,.offset, and.opacityare GPU-accelerated and nearly free. Properties that trigger layout recalculations —.frame(),.padding(), conditional views — are more expensive. Prefer transforms whenever possible. - Avoid animating in
bodyrecomputations. If your animation causes the view body to recompute on every frame, performance drops. Use.drawingGroup()to flatten complex view hierarchies into a single rendered layer. - Use
.drawingGroup()for complex overlapping shapes. When you have many layers of gradients, shadows, and blurs animating simultaneously,.drawingGroup()rasterizes the view into a Metal texture, dramatically improving performance. - Limit
matchedGeometryEffectscope. Do not apply it to dozens of views simultaneously. Match only the primary element (image, card background) and use separate, simpler transitions for secondary content. - Profile with Instruments. Use the SwiftUI Instruments template in Xcode to identify which views are being redrawn during animations. The “View Body” instrument shows exactly how many times each view's body is called per frame.
- Prefer
.contentTransition(.numericText())over conditionally swappingTextviews for animated number changes. It is optimized internally and avoids full view replacement.
Quick test: Enable Debug > Slow Animations in the iOS Simulator (Cmd + T) to see your animations at one-fifth speed. Jank that is invisible at normal speed becomes obvious when slowed down.Animation Debugging Tips
When an animation does not behave as expected, here is a systematic debugging checklist:
- Is the state change wrapped in
withAnimation? If you are using explicit animations, every state mutation that should animate must be inside the closure. State changes outside the closure will snap instantly. - Are you using
.animation(_:value:)with the correct value? If the value you pass does not actually change, the animation never fires. Double-check that the value parameter matches the state driving the visual change. - Is the view being removed and re-inserted instead of updated? If your conditional logic creates a new view identity (different
id, different position in the view tree), SwiftUI treats it as a removal + insertion, not an update. Transitions fire instead of property animations. - Check for conflicting animations. If both
.animation()andwithAnimationaffect the same property, the behavior can be unpredictable. Pick one approach per property. - Use
.transactionto inspect animation state. The.transactionmodifier lets you log or override the current animation context, which helps identify where animations are being applied or overridden.
Ship Polished Animations Without Starting from Scratch
Every animation pattern covered in this guide — spring-driven onboarding transitions, shimmer loading states, button feedback, hero matched geometry effects — requires careful tuning to feel right. Getting the spring stiffness, duration, and bounce dialed in for each interaction takes hours of iteration.
The Swift Kit ships with production-polished animations baked into every component. The onboarding flow uses staggered spring entrances across three customizable templates. The paywall uses a matched geometry hero transition for plan selection with a bounce that feels native. Loading states include shimmer skeletons. Buttons have scale feedback with haptic integration. Every animation has been tested on devices ranging from iPhone SE to iPhone 16 Pro Max to ensure consistent 60fps performance.
Instead of building and tuning these animation patterns from scratch, you can start with a codebase where every interaction already feels polished. Head over to the pricing page to see what is included, or explore the features overview to see the animations in action. Every animation in the kit uses the techniques from this guide — so you will understand exactly how they work and can customize them to match your brand.