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.
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:
- The transaction doesn’t contain the animation you think it does.
- 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.
Explicit animation (recommended): withAnimation
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:
withAnimationfor 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
withAnimationfor 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.