Vedran Burojevic
← WritingFebruary 1, 2026

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.

On this page

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.

Command menu

Navigate the site or run an action