Skip to content
·5 min read

SwiftUI Animations That Don't Look Like PowerPoint

Default SwiftUI animations look like 2003 Keynote. .easeInOut everywhere, no springs, no choreography. Here's how to make them feel premium.

SwiftUIiOSAnimationMobile

Default SwiftUI animations look like 2003 Keynote. You slap .animation(.easeInOut) on something, it slides, it works, you ship it. The app feels cheap and you don't know why.

The answer: real-world motion doesn't move in straight lines. It doesn't start and stop instantly. It has weight, it overshoots, it settles. SwiftUI gives you the tools to do this, almost nobody uses them.

Here's how I make SwiftUI animations feel premium, not PowerPoint.

The single biggest upgrade: springs, not easing curves

// Cheap animation
withAnimation(.easeInOut(duration: 0.3)) {
    isExpanded.toggle()
}
 
// Premium animation
withAnimation(.spring(response: 0.4, dampingFraction: 0.8)) {
    isExpanded.toggle()
}

That's it. That's the whole trick. .easeInOut reads as a web transition. .spring reads as a native Apple animation because every iOS system animation uses springs internally.

Tune the parameters:

  • response, how long the animation takes to settle. 0.3-0.5 covers most UI. Higher = slower, floatier.
  • dampingFraction, how much it overshoots. 1.0 = no overshoot (stiff). 0.7 = gentle overshoot. Below 0.6 = bouncy, playful.

For a button press: response: 0.2, dampingFraction: 0.7. For a sheet presentation: response: 0.5, dampingFraction: 0.9. For a playfulness pop: response: 0.3, dampingFraction: 0.6.

Animate the right property, or it will jank

This is the silent killer. Animating the wrong property is why most SwiftUI animations jank on older devices.

// WRONG: triggers layout every frame
withAnimation {
    frame = CGRect(x: 0, y: offset, width: 100, height: 100)
}
 
// RIGHT: GPU-composited, stays at 120fps
withAnimation {
    offset = 100
}

The properties you should animate:

  • opacity
  • scaleEffect
  • offset
  • rotationEffect

The properties you should NOT animate:

  • Anything inside frame()
  • padding
  • position (use offset instead)
  • Layout-affecting modifiers

GPU-composited properties don't trigger layout passes. The view stays where it is and the GPU just moves, scales, or fades the texture. That's why Apple's own apps stay smooth on old devices.

withAnimation vs .animation, the actual difference

These are not interchangeable. Using the wrong one is the most common SwiftUI bug I see.

// withAnimation: wraps a STATE CHANGE
// Use when: user taps a button, toggles a sheet, switches tabs
withAnimation(.spring()) {
    isSelected.toggle()
}
 
// .animation: binds to a VALUE
// Use when: dragging, scrubbing, continuous input
.offset(y: dragOffset)
.animation(.spring(), value: dragOffset)

Rule: if the change comes from a user gesture that completes (tap, swipe to dismiss), use withAnimation. If the change is continuous and follows a finger, use .animation(_:value:).

The iOS 17+ alternative is the withAnimation spring parameters or the new keyframe-based APIs, but the gesture rule still applies.

Phase animations (iOS 17+) for sequences

Before iOS 17, chaining animations with delays required DispatchQueue.asyncAfter or custom TimelineView setups. Both were ugly.

MyView()
    .phaseAnimator([false, true]) { content, phase in
        content
            .scaleEffect(phase ? 1.1 : 1.0)
            .opacity(phase ? 0.9 : 1.0)
    } animation: { phase in
        phase ? .spring(response: 0.3, dampingFraction: 0.6)
              : .spring(response: 0.4, dampingFraction: 0.8)
    }

This runs forever, a gentle pulse. No timers, no state. The system handles the loop.

For one-shot sequences, use KeyframeAnimator:

MyView()
    .keyframeAnimator(initialValue: .zero) { content, value in
        content.offset(value.offset)
    } keyframes { _ in
        KeyframeTrack(\.offset) {
            CubicKeyframe(0, duration: 0.2)
            SpringKeyframe(40, duration: 0.3)
            CubicKeyframe(0, duration: 0.4)
        }
    }

Real choreography. Real Apple-feeling animation.

matchedGeometryEffect, the hero transition

This is the one that makes people say "how did they do that?"

@Namespace private var namespace
 
if !isExpanded {
    SmallView()
        .matchedGeometryEffect(id: "hero", in: namespace)
        .onTapGesture { withAnimation(.spring()) { isExpanded = true } }
} else {
    LargeView()
        .matchedGeometryEffect(id: "hero", in: namespace)
        .onTapGesture { withAnimation(.spring()) { isExpanded = false } }
}

The system animates the view from its small frame to its large frame. No manual math. No transition coordinator. It just works (mostly, read on).

The catch: matchedGeometryEffect is finicky. The two views can't be in different scroll views. The namespace has to match. Switching between if and if-else won't work the same way. When it breaks it falls back to a crossfade, still works, just not the hero effect.

The workflow I actually use

  1. Replace every .easeInOut with .spring(response: 0.4, dampingFraction: 0.8)
  2. Audit every animation, am I animating frame or offset? Switch to GPU-composited.
  3. Use withAnimation for taps, .animation(_:value:) for drags.
  4. Reach for matchedGeometryEffect for hero transitions between list and detail.
  5. Use phase animations for ambient motion (loading, success states).

Steps 1 and 2 are 80% of the win. The rest is polish that pays off when you have time.

SwiftUI animations don't need to be clever. They need to use springs instead of lines, and animate offset instead of frame. Everything else is garnish.

Want a SwiftUI app that feels like Apple built it?

I build iOS apps with the animation polish users feel without thinking about. Let's talk.