Back to Blog

SwiftUI animations that don’t glitch: transactions, explicit vs implicit, and performance-safe patterns

A practical SwiftUI animation guide: how transactions actually work, why animations disappear, and how to ship smooth UI without expensive re-renders.

4 min read

SwiftUI animations are great right up until they’re not.

The failure modes are always the same:

  • it animates once, then never again
  • a list animates the wrong row
  • something “teleports” instead of transitioning
  • everything animates even when you didn’t ask

This isn’t black magic. It’s usually one of two things:

  1. The transaction doesn’t contain the animation you think it does.
  2. The view identity changed, so SwiftUI had nothing to animate between.

Transactions: the missing mental model

Every state change in SwiftUI happens inside a transaction.

A transaction is the “context of this update”. It can carry:

  • whether animations are enabled
  • which animation (if any) should be used

If you internalize that, 80% of SwiftUI animation weirdness becomes debuggable.

withAnimation(.snappy) {
    isExpanded.toggle()
}

This is the cleanest intent: “animate this mutation.”

Implicit animation (use carefully): .animation(_, value:)

.animation(.snappy, value: isExpanded)

This means: “when isExpanded changes, animate changes in this subtree.”

It can be great. It can also make unrelated changes animate because it’s scoped to a subtree, not a single mutation.

The fastest way to make your UI un-debuggable

Global implicit animation:

VStack {
    // lots of UI
}
.animation(.easeInOut)

Now you get “why is this animating?” and “why did this stop animating?” at the same time.

Prefer:

  • withAnimation for user-triggered changes
  • .animation(_, value:) only when the animated region is small and obvious

Why animations disappear: identity changed

SwiftUI animates between two render passes for the same identity.

If identity changes, SwiftUI treats it as:

  • old view removed
  • new view inserted

…and you get teleportation.

Common foot-gun: id(UUID())

SomeView()
    .id(UUID())

That tells SwiftUI “this is a brand new view every time.” There’s nothing to animate between.

List foot-gun: unstable ForEach identity

ForEach(items.indices, id: \.self) { i in
    Row(item: items[i])
}

Indexes are not stable identity when the array changes.

Prefer:

ForEach(items, id: \.id) { item in
    Row(item: item)
}

If you’ve ever had “the wrong row animated”, this is usually the reason.

Disabling animation intentionally (bulk updates)

Sometimes you want the opposite of “smooth”: you want “stop moving and just update”.

Example: network refresh replaces a list and you don’t want a whole re-layout animation.

withTransaction(Transaction(animation: nil)) {
    model.items = freshItems
}

Or scoped:

.transaction { tx in
    tx.animation = nil
}

Use this around bulk updates that look jittery or cost real CPU.

Transitions vs animating properties

Use transition for insertion/removal:

if isExpanded {
    Details()
        .transition(.opacity.combined(with: .move(edge: .top)))
}

If the view stays in the tree and only changes appearance, prefer animating properties:

Details()
    .opacity(isExpanded ? 1 : 0)
    .offset(y: isExpanded ? 0 : -8)

Rule of thumb:

  • enters/leaves hierarchy → transition
  • stays and changes appearance/layout → animate properties

Performance: animate cheap things

If you stutter during animation, it’s usually because you’re doing expensive work inside the frame budget.

Common culprits:

  • image decoding
  • heavy text layout changes
  • expensive formatting/parsing inside body
  • list diffing churn

Practical fixes:

  • precompute and cache
  • keep list identity stable
  • keep the animated subtree small

Debugging: inspect the transaction

If you’re stuck, inspect the transaction value:

.transaction { tx in
    // breakpoint here, inspect tx.animation
}

This is often faster than blindly moving .animation modifiers around.

What I actually do in real apps

  • Use withAnimation for user actions.
  • Keep .animation(_, value:) narrowly scoped.
  • Treat identity as sacred (no random ids; stable list ids).
  • Disable animation around bulk refreshes.

Do that and SwiftUI stops acting “random”. It was never random. It was just following your instructions.