← All shorts
14iOS 16+2 min read

contentTransition(.numericText()): Odometer-Style Numbers

Roll digits up or down like an odometer when a number changes, with one modifier and no custom digit views.

Watch a score count up. It should feel like digits rolling into place. Not a hard cut, not a flat crossfade.

Building that by hand used to mean slicing the number into individual digit views and animating each one's vertical offset separately.

#The old way

struct ScoreCounter: View {
    @State private var score = 0

    var body: some View {
        VStack(spacing: 20) {
            Text(score, format: .number)
                .font(.system(size: 48, weight: .bold, design: .rounded))
                .id(score)
                .transition(.opacity)
                .animation(.easeInOut, value: score)

            Button("Add 10") {
                score += 10
            }
        }
    }
}

That's a crossfade. The whole number fades out, the whole number fades back in. It's a jump cut with a blur on it, not a roll.

#The new way

import SwiftUI

struct ScoreCounter: View {
    @State private var score = 0

    var body: some View {
        VStack(spacing: 20) {
            Text(score, format: .number)
                .font(.system(size: 48, weight: .bold, design: .rounded))
                .monospacedDigit()
                .contentTransition(.numericText(value: Double(score)))
                .animation(.snappy, value: score)

            Button("Add 10") {
                score += 10
            }
        }
    }
}

#Why it matters

  • Every digit that changes rolls on its own, in the direction the value moved: up when it increases, down when it decreases. That's the behavior users already know from a native counter or a stopwatch.
  • Digits that don't change stay put. The leading "1" in 19 → 10 holds still instead of the whole string re-animating.
  • One modifier on a plain Text. No per-digit view decomposition, no manual offset math.

#Gotcha

.contentTransition(.numericText()) does nothing on its own. It defines how to animate a change, not that a change should animate. The mutation still has to happen inside withAnimation or alongside an .animation(_:value:) modifier, exactly as shown above. Skip that and the number jumps instantly.

Pair it with .monospacedDigit(), always. Without fixed-width digits the string's width changes as digits roll, and the whole Text visibly jitters horizontally mid-animation.

swiftuiios16animation

Related shorts