Debugging SwiftUI Animation Glitches Caused by Implicit Transactions
You tap a button. A label updates. And then, for reasons that seem to have nothing to do with the code you just wrote, a completely different view slides across the screen. You didn’t wrap anything in withAnimation. You didn’t attach .animation(_:) to that view. Yet it moved.
This is the signature of an implicit transaction carrying animation context further than you intended. The fix is rarely a modifier reorder. It’s understanding that animation in SwiftUI is transaction-scoped state, not view-scoped state — and then finding the transaction that picked it up.
Animation lives on the transaction, not the view
Apple’s own description of the update cycle is the clearest place to start. In the WWDC23 session Explore SwiftUI animation, the presenter walks through what happens when an event arrives:
When an event, like a tap, comes in, an update transaction is opened. If any of its dependencies change, the view is invalidated, and at the close of the transaction, the framework calls body to produce a new value in order to refresh the rendering.
That transaction is the unit of work. It opens, dependencies invalidate, bodies re-run, and it closes. Animation is a property of that transaction — not of any individual view, and not of any individual modifier.
When you call withAnimation, you are setting the animation for the transaction that is currently open. The same session states it plainly: wrapping a state change in withAnimation means “the animation is set for the transaction.” Everything invalidated inside that transaction inherits it.
This is the part that trips people up. A view does not need to be near the state change to receive the animation. It only needs to be invalidated by the same transaction.
What an animatable attribute does with that context
Not every view responds to the transaction’s animation. Only animatable attributes do. The session describes the check they perform:
When the value of an animatable attribute changes, it checks if an animation is set for the transaction. If so, it makes a copy and uses the animation to interpolate from the old value to the new value as time passes.
Two details matter for debugging.
First, the attribute copies the animation at the moment its value changes. The animation a view ends up running depends on which transaction invalidated it — not on which modifier sits closest to the state change in your source file.
Second, animatable attributes have both a model value and a presentation value. The session notes that “animatable attributes conceptually have both model and presentation values.” The model value is what your state says. The presentation value is what’s currently on screen, mid-interpolation. When a glitch looks like a view animating from the wrong starting point, you are usually looking at a presentation value that was captured mid-flight.
Why re-triggered animations look wrong
Here is the mechanism behind a whole class of “it animates from the wrong place” bugs.
The animation running inside an animatable attribute does not interpolate absolute values. It interpolates the delta between the old and new vectors. The session gives a concrete example using scaleEffect:
This means, in practice, the animation running in the scaleEffect animatable attribute isn’t interpolating from 1 to 1.5, but from 0 to 0.5.
Now suppose a second state change fires while the first animation is still running. The attribute has to decide what to do with the new animation. That decision is made by shouldMerge, and the behavior differs by animation type:
The default implementation returns false — this is what timing curve animations do — in which case both animations will be run together, and their results will be combined by the system.
Springs behave differently:
Spring animations override shouldMerge to return true and incorporate the state of the previous animation. This allows them to preserve velocity and retarget to the new value, which can feel more natural than combining additively, like timing curve animations.
This is why identical code can produce visibly different artifacts depending on the animation type. A timing curve that gets re-triggered mid-flight combines additively with the running animation. A spring retargets. If you have been chasing a glitch by reordering modifiers while the real variable is which animation type is running and whether a previous one is still in flight, you have been looking in the wrong place.
One more relevant detail: since iOS 17 and aligned releases, a bare withAnimation defaults to a smooth spring. If your code predates that change and you were relying on timing-curve merge behavior, the default may have shifted underneath you.
A diagnostic order that actually converges
When you hit an animation glitch you didn’t ask for, resist the urge to start moving modifiers. Work through the transaction first.
1. Confirm the transaction
Ask: is an animation set on the transaction at all, and who set it? The most common sources are a withAnimation call somewhere in the same update, a view-level .animation(_:) modifier that applies implicitly, or a container that propagates animation context to its children. If you can’t identify the source, you can’t fix the bug — you can only move it.
2. Confirm the attribute is animatable
Only animatable attributes respond to the transaction’s animation. If a view is moving and you don’t think it should be, check whether the property driving it is animatable — position, scale, opacity, and similar. A non-animatable change won’t interpolate regardless of what the transaction says.
3. Confirm whether a prior animation is still running
If the glitch is a wrong starting point rather than an unwanted animation, the cause is likely a merge. Check whether the state change is firing while a previous animation is still in flight, and check the animation type. A timing curve and a spring will produce different visible results from the same re-trigger.
4. Decide where the fix belongs
There are two legitimate places to fix this, and they solve different problems.
If the animation is unwanted entirely for a specific update, the transaction itself is the right place to suppress it. Transaction.disablesAnimations exists for exactly this: opting a transaction out of animation. Apply it as a scoped decision around the state change that shouldn’t animate, not as a blanket fix across your app. Suppressing animation on a transaction removes the animation context for everything invalidated in that transaction — which is the same propagation behavior that caused the bug in the first place.
If the animation is wanted but is leaking into views that shouldn’t receive it, the durable fix is to make the animation boundary explicit at the state-change site. Instead of relying on ambient animation context to propagate through a large transaction, scope the animation to the specific change that should animate. This is more code, but it makes the intent legible and prevents the next state change from inheriting an animation it never asked for.
A note on custom Animatable conformances
If your glitch involves a view that conforms to Animatable directly, there’s an additional cost to be aware of. The session warns:
Just keep in mind that a custom Animatable conformance can be much more expensive to animate than a built-in effect because it’ll run body for every frame of the animation.
This doesn’t cause glitches by itself, but it changes the timing of when your body runs relative to the transaction, which can make a merge-related glitch harder to reason about. If you’re debugging a custom animatable view, treat the per-frame body execution as part of the picture.
The mental model to keep
Animation in SwiftUI is transaction-scoped state. A view receives an animation because it was invalidated by a transaction that had one set — not because of where a modifier sits in your source. When you see an animation you didn’t ask for, or one that starts from the wrong value, the question is not “which modifier is wrong?” It’s “which transaction set this animation, and what else did that transaction invalidate?”
Answer that, and the fix usually becomes obvious. Skip it, and you’ll spend an afternoon reordering modifiers that were never the problem.
Frequently asked questions
Why does a view animate when I never called withAnimation?
Because animation is set on the transaction, not on the view. If any transaction that invalidates the view has an animation set — whether by withAnimation, a view-level .animation(_:) modifier, or a container propagating context — the view’s animatable attributes will pick it up. Find the transaction, not the view.
Why does my animation start from the wrong value when I tap quickly?
The animation interpolates the delta between model values, and animatable attributes hold both model and presentation values. When a second animation begins while a first is still running, shouldMerge determines whether the new animation combines with the running one (timing curves) or retargets from the current presentation value (springs). The visible starting point depends on which behavior applies.
Should I use transaction.disablesAnimations to fix this?
It’s the right tool when you deliberately want a specific update to skip animation. It’s the wrong tool as a blanket fix, because it removes the animation context for everything invalidated in that transaction — the same propagation behavior that caused your bug. Scope it to the update that shouldn’t animate.
Does modifier order matter?
The retrieved Apple documentation excerpts do not establish a causal ordering rule for animation glitches. The mechanism described in Apple’s session is transaction-scoped, which means the transaction that invalidates a view determines its animation — not the position of a modifier relative to another modifier. If you’re reordering modifiers to fix an animation glitch, you’re probably treating a symptom.
Where can I read the primary source for this?
Apple’s WWDC23 session Explore SwiftUI animation (developer.apple.com/videos/play/wwdc2023/10156/) walks through the transaction model, animatable attributes, model versus presentation values, delta-vector interpolation, and shouldMerge behavior. The Transaction documentation page covers the type itself, including animation and disablesAnimations.