SwiftUI contentTransition in iOS 26: The Complete Guide to Text, Numeric, and Symbol Animations
Animate text, numbers, and SF Symbols with SwiftUI's contentTransition in iOS 26. Odometer counters, glyph morphs, symbol replacements, plus production motion values and Reduce Motion handling.
SwiftUI's contentTransition is a view modifier that animates changes to a view's content (the glyphs inside a Text, the strokes of an SF Symbol, the digits of a counter) rather than the view being added or removed. In iOS 26 it supports four built-in styles (.identity, .opacity, .interpolate, and .numericText), plus a .symbolEffect flavor for SF Symbols, all of which activate the moment you wrap the state change in withAnimation. It's the difference between numbers popping and numbers rolling.
Honestly, I burned an evening on this the first time I shipped a live workout counter. The digits were hard-swapping, the design review team was unimpressed, and it took me longer than I care to admit to notice I'd forgotten the withAnimation wrapper. So let's save you that hour.
contentTransition(_:) animates content changes inside a view; transition(_:) animates the view appearing or disappearing. They are not interchangeable.
.numericText(countsDown:) produces an odometer effect for digit changes; use .numericText(value:) when you want SwiftUI to decide direction from the raw value.
.interpolate smoothly morphs between glyphs - best for short strings and monospaced fonts.
Nothing animates without withAnimation { ... } or an equivalent explicit animation wrapper on the state change.
For SF Symbols, prefer symbolEffect(.replace) in iOS 17+; use contentTransition(.symbolEffect(.replace)) only when the symbol is inside a Label or shared text run.
Respect @Environment(\.accessibilityReduceMotion) - collapse animations to a crossfade when Reduce Motion is on.
What is contentTransition in SwiftUI?
contentTransition is the modifier you reach for when the view stays on screen but its content changes and you want the swap animated. Think of a workout timer whose digits tick over, a like-count that ticks up, a play button that morphs into a pause button. The view identity is stable; only the rendered content changes. The modifier attaches a ContentTransition value to the environment for descendants that know how to consume it (today, that primarily means Text, Image for SF Symbols, and any custom view that reads the environment via the contentTransition environment key).
The API shape is deliberately narrow. You choose one of the four bundled styles - .identity, .opacity (the default), .interpolate, or .numericText - and let SwiftUI handle the rest. There is no configuration surface for spring curves or per-glyph timing; the animation curve comes from whatever wraps the state change. That constraint is the whole point: SwiftUI wants motion to be systemic, not bespoke per-Text.
Under the hood, when a content transition fires, SwiftUI captures both the outgoing and incoming rasterizations and blends them along the driving animation's timing function. For .numericText, it also runs a lightweight diff over the digits so only the digits that changed slide, while the rest hold still. Understanding that mental model - capture, diff, blend - makes the rest of this article click.
contentTransition vs transition: what is the difference?
They sound similar, and they sit next to each other in autocomplete, so this is the single most common source of confusion. Short answer: transition(_:) animates a view being inserted or removed from the hierarchy; contentTransition(_:) animates changes to the content of a view that stays put. If your text view is present before and after the state change, you want contentTransition. If your text view appears or disappears (usually because it lives inside an if or ForEach), you want transition.
The practical consequence: transition requires the view's identity to change (via if, id(), or a conditional in the layout tree), and it needs a matching implicit or explicit animation on the parent. contentTransition requires the opposite - stable identity, changing content, and an explicit animation wrapping the mutation. Mixing them causes the classic "why does my transition sometimes fire twice, sometimes not at all" behavior.
Aspect
contentTransition
transition
What animates
Content inside a stable view
The view itself entering or leaving
View identity
Must remain the same
Must change (view added/removed)
Typical trigger
State value mutates
if/switch branch flips
Needs withAnimation
Yes, always
Yes, on the identity change
Custom curves
Inherited from wrapping animation
Configurable per side (asymmetric)
Best for
Counters, symbols, live text
Sheets, cards, list rows
A concrete side-by-side
Imagine a badge counter. If you write if count > 0 { Text("\(count)") }, then transition is what shows the badge appearing when the count leaves zero, and content transition is what animates the digits changing from 3 to 4 while it is visible. Both may apply to the same view over its lifetime.
How do you animate numbers in SwiftUI?
The odometer effect (where individual digits slide up or down as a value increments or decrements) is a three-line change in iOS 26. Attach .contentTransition(.numericText(countsDown: value < oldValue)) to the Text, wrap the mutation in withAnimation, and use a monospaced digit font so the columns line up. That's it, really.
Two choices matter. The .numericText(value:) variant lets SwiftUI infer direction from the numeric value itself; passing Double(steps) is enough. The .numericText(countsDown:) variant is the manual override for cases where you want a decrement to visually go up (a stopwatch counting up while showing remaining time, for example). I default to the value-based form because it stays correct when I forget to update the trigger condition, which is more often than I'd like.
Building an odometer that reads well
Odometer animations look cheap if the digits jitter horizontally. Three things fix that: use .monospacedDigit() (or a monospaced font design), pin the text alignment (.frame(minWidth:) with a fixed width for the widest expected value), and format with Text(value, format: .number) so the digit-grouping separators don't shift width when the value crosses a thousand boundary. Ship it once at 96pt in a demo and you will never accept a wobbly counter again.
Animating currency and percentages
Both work - the format style is orthogonal. For currency, use Text(amount, format: .currency(code: "USD")) and the digits animate while the $ holds still. For percentages, Text(ratio, format: .percent.precision(.fractionLength(1))). The one gotcha: switching between "1,000" and "999" changes the number of comma-separated groups, and SwiftUI treats the comma as a glyph swap. It is subtle but visible on slow devices; if you have room, always render with the grouping separator to keep the column stable.
Animating text changes with .interpolate
The .interpolate style morphs between the outgoing and incoming rasterizations of a Text. It looks best for short strings (a status label, a title in a shared element, a segment header) and worst for long paragraphs, where the intermediate frames turn into a smear. My rule of thumb: if the string is longer than about 20 characters or wraps to more than one line, fall back to .opacity.
enum ScanState { case idle, scanning, done, error }
struct StatusLabel: View {
let state: ScanState
var body: some View {
Text(label)
.font(.system(.title2, design: .rounded, weight: .medium))
.foregroundStyle(color)
.contentTransition(.interpolate)
.animation(.smooth(duration: 0.35), value: state)
}
private var label: String {
switch state {
case .idle: "Ready"
case .scanning: "Scanning..."
case .done: "Complete"
case .error: "Try again"
}
}
private var color: Color {
switch state {
case .idle, .scanning: .primary
case .done: .green
case .error: .red
}
}
}
Note the .animation(_:value:) modifier attached to the Text. This is the value-scoped implicit animation form, and it's my preferred pattern for content transitions on labels; it means anywhere in the app that state mutates, the label animates, without every callsite needing a withAnimation block. For counters wired to gestures I still use withAnimation at the callsite because it keeps the intent local to the interaction.
Does contentTransition work with SF Symbols?
Yes, with a caveat that trips people up. There are two overlapping APIs: contentTransition(.symbolEffect(.replace)), which lives on the general-purpose modifier chain, and the dedicated symbolEffect(.replace, ...) modifier introduced in iOS 17. They produce similar-looking transitions when a symbol's Image(systemName:) value changes, but they're aimed at different situations.
Use symbolEffect(.replace) when the Image is a standalone view. It gives you configuration knobs (.byLayer, .wholeSymbol, .upUp, .offUp, and .magic in iOS 26) that contentTransition doesn't expose. Use contentTransition(.symbolEffect(.replace)) when the symbol is inside a Label, sharing a text run with a title, and you want it to animate in sync with any surrounding text mutations. Mixing both on the same view isn't additive; the outer modifier wins, so pick one and stay consistent.
The .replace.magic variant is the iOS 26 add. It does the by-layer morph that Apple showed off in the WWDC24 SF Symbols session, with an explicit fallback to plain .replace on older systems. For a deeper walk-through of symbol animation configurations, see the complete guide to symbolEffect and animated SF Symbols, which covers the discrete/indefinite/transition axis in detail.
Pairing symbol transitions with haptics
A visual state change without matching physical feedback feels flat, especially for toggle-shaped controls. I attach a .sensoryFeedback(.impact(weight: .light), trigger: isPlaying) to any button whose primary job is a state flip. The soft impact aligns with the symbol's morph peak because both animations are driven by the same state and inherit similar timing. If you want to tune haptic patterns beyond the presets, my iOS 26 haptics deep-dive covers Core Haptics and custom AHAP files.
Driving the animation: withAnimation and transactions
The content transition is only the choreography; the timing comes from whatever animation is wrapping the state mutation. There are three ways to attach that timing and they are not interchangeable.
withAnimation(_) { state = new }: imperative, callsite-local. Best for gesture handlers, button actions, and anything driven by an event.
.animation(_, value:) attached to the view: declarative, applies whenever the tracked value changes. Best for labels that mutate from many places.
Transaction: the low-level primitive both of the above use. Reach for it only when you need to override an inherited transaction (for example, to disable the transition on a specific update).
If you attach .contentTransition(.numericText()) but change steps outside any animation context, the digit will hard-swap. That's the single most common bug, and it's not a bug (it's the design). Content transitions are opt-in per mutation, which lets you decide "this update animates, this one just updates."
Disabling a specific update
Sometimes you want a big jump - resetting a counter to zero, hydrating from server on launch - to skip animation. Wrap that one mutation:
The rest of the app keeps its normal animation behavior; only this update lands instantly.
Why is my contentTransition not animating?
Ninety percent of the "it doesn't animate" reports collapse into one of five root causes. Walk this list top-to-bottom and you'll resolve nearly all of them in under two minutes. I keep this list bookmarked because I hit numbers three and four so often on my last project.
No animation wrapper. The state change isn't inside a withAnimation block, and there's no .animation(_, value:) modifier bound to the changing value. Fix: wrap the mutation, or bind an animation to the value.
The view is being recreated. If the parent uses if, ForEach with an unstable id, or any pattern that changes the child's identity, SwiftUI treats it as a new view; content transition never gets a chance because there's no "previous content" to blend from. Fix: pull the Text outside the conditional and vary its content instead of its existence.
.numericText on non-numeric content. Passing a formatted date, a string like "1st place", or a value with a percent sign that isn't the standard formatter output causes the diff to fail silently and the label to opacity-swap instead. Fix: use Text(value, format: .number) (or another FormatStyle) and rely on the format style rather than string-concatenation.
Overriding parent transaction. A wrapping view with .transaction { $0.animation = nil } or .animation(nil, value:) disables all animation for descendants. Fix: search up the hierarchy for the killswitch and remove or scope it.
Reduce Motion is on. Some system-level animations respect Reduce Motion automatically and either shorten or crossfade. This is not a bug; it's the intended accessibility behavior. Fix: read @Environment(\.accessibilityReduceMotion) and provide the crossfade branch explicitly if you want to confirm it's intentional.
Motion design values I actually ship
Content transitions inherit their timing from the driving animation, which means the wrong curve makes a beautifully-conceived odometer feel wrong for reasons users can't articulate. Here are the values I've converged on across three shipped apps. None of these are Apple gospel; they're what I've measured to feel right on hardware.
Digit counters (single-digit change):.spring(response: 0.35, dampingFraction: 0.85). Fast enough that rapid taps don't queue; damped enough that the final digit doesn't overshoot into unreadability.
Digit counters (multi-digit jump):.spring(response: 0.5, dampingFraction: 0.9). A longer response lets the eye track cascading columns.
Text label morphs (.interpolate):.smooth(duration: 0.35). Springs on glyph interpolation look mushy; the ease-in-out feel of .smooth reads as intentional.
Symbol replacements:.spring(response: 0.4, dampingFraction: 0.8). Slightly under-damped so the icon feels alive on press.
Bulk mutations (initial load, reset): no animation. Explicitly wrap in Transaction(animation: nil).
Pair every one of these with the matching sensory feedback: light impact for toggles, success for confirmation, decrease for undo. The motion and the haptic land at the same point in the animation curve because both are driven by the same state; you don't have to manually align them.
Respecting Reduce Motion and accessibility
Roughly one in seven users has Reduce Motion turned on. Some for vestibular reasons, some because they simply prefer stiller interfaces. SwiftUI doesn't automatically neutralize contentTransition when Reduce Motion is on, which is intentional: sometimes the transition is the information (a counter that changes without motion can be missed entirely). It's your job to decide.
struct AccessibleCounter: View {
@Environment(\.accessibilityReduceMotion) private var reduceMotion
@State private var value: Int = 0
var body: some View {
Text(value, format: .number)
.monospacedDigit()
.contentTransition(reduceMotion ? .opacity : .numericText(value: Double(value)))
.animation(reduceMotion ? .linear(duration: 0.15) : .spring(response: 0.4, dampingFraction: 0.85),
value: value)
}
}
The pattern: switch the ContentTransition style itself, not just the driving curve. .opacity respects the user's request to avoid horizontal motion while still communicating "something changed." Pair it with a shorter, linear duration so the crossfade doesn't linger. If you also emit haptics on the change, they carry more of the perceptual weight when motion is dampened, which is another reason I treat haptics as a first-class citizen rather than a garnish. For the fuller accessibility story (Dynamic Type, VoiceOver labels, focus order), see the deep-dive on SwiftUI animations, springs, keyframes, and transitions.
What is the difference between contentTransition and transition in SwiftUI?
contentTransition animates changes to the content of a view whose identity is stable (a Text whose string mutates, a counter whose number ticks). transition animates a view being inserted into or removed from the hierarchy (a sheet appearing, a row being deleted). Same word, opposite jobs.
Does contentTransition require iOS 16 or later?
Yes. contentTransition(_:) was introduced in iOS 16, macOS 13, watchOS 9, and tvOS 16. The .symbolEffect case requires iOS 17+, and the .magic replace variant requires iOS 18+. On iOS 15 and earlier, the modifier is unavailable and the swap will happen without animation.
How do I animate currency values with contentTransition?
Format the value with Text(amount, format: .currency(code: "USD")), attach .contentTransition(.numericText(value: amount)), and wrap the mutation in withAnimation. The digits animate while the currency symbol and grouping separators hold still. Use .monospacedDigit() to prevent column jitter.
Why does .numericText sometimes just crossfade instead of rolling?
The label is not being recognized as numeric. This happens when you concatenate a string ("Score: \(value)") instead of using a FormatStyle. Rebuild the label as Text("Score: ") + Text(value, format: .number) so the numeric portion is a distinct Text that contentTransition can diff.
Can I use contentTransition with a custom SwiftUI view?
Yes. Read the environment key @Environment(\.contentTransition) from inside your custom view and branch on the transition case. Most apps never need this because Text and Image already consume it, but it's the escape hatch for a fully custom animated readout (dial, gauge, wave form).
A practical guide to Apple's unified logging in Swift for iOS 26: how Logger replaces print, when to use each log level, how privacy annotations protect PII, and how OSLogStore and OSSignposter power in-app diagnostics and performance tracing.
SwiftUI's PhaseAnimator walks a view through ordered animation phases from a single trigger, replacing @State flags and asyncAfter chains. Learn per-phase curves, trigger patterns, and how to combine it with CustomAnimation, plus real-world code for like buttons and toasts.
Profile SwiftUI apps with Xcode Instruments in Xcode 26. A real-device workflow for finding hangs, frame drops, and memory leaks, plus custom signposts with OSSignposter.