Skip to content
Tracking Breathe preview
An animated render of the SwiftUI source on this page.

Tracking Breathe

From syxUI — written for both platforms, not translated between them.

Letter-spacing opening and closing on a slow breath, dead centred.

Text Effects · SwiftUI · Flutter · iOS 17 · Flutter 3.27

  • text
  • tracking
  • letter spacing
  • breathe
  • loop
  • minimal
  • animation

The actual source

TrackingBreathe.swift
// Tracking Breathe · syxUI · https://syxui.dev/components/tracking-breathe
// Free in any project. Keep this line and credit syxUI where a person can read it.

import SwiftUI

/// Letter-spacing opening and closing on a slow breath, dead centred.
///
/// One `Text`, so kerning and shaping survive completely — the reason this
/// is a single view with an animated `.tracking()` rather than an `HStack`
/// of characters with animated spacing, which is the more obvious route but
/// throws real shaping away for nothing this effect needs.
struct TrackingBreathe: View {
    var text: String = "BREATHE"
    var minTracking: CGFloat = 1
    var maxTracking: CGFloat = 9
    /// One full breath, in seconds.
    var period: Double = 3.6
    var uppercase: Bool = true
    var color: Color = Color(white: 0.07)
    /// Preview override. `0...1` is exactly one breath, and the loop is
    /// seamless at the wrap since the wave is a plain cosine of the phase.
    var phase: Double? = nil

    @Environment(\.accessibilityReduceMotion) private var reduceMotion
    @State private var start = Date()

    private let fontSize: CGFloat = 34
    /// How long the entrance settle takes, in milliseconds. Not exposed as
    /// a control — it only ever plays once, on the live (non-`phase`) path.
    private let entranceDuration: Double = 900

    var body: some View {
        // `.tracking` is a `Text`-level modifier, not an animatable
        // property, so `withAnimation` on a `@State` value only ever shows
        // its resting value. `TimelineView` recomputes it every frame
        // instead — one `Text`, one layout pass per frame, which is cheap.
        TimelineView(.animation(paused: phase != nil || reduceMotion)) { context in
            let (tracking, opacity) = state(at: context.date)

            display
                .tracking(tracking)
                .opacity(opacity)
                // `.tracking` pads the trailing edge of the line too, so a
                // centred block drifts left as it widens. Nudging right by
                // half the tracking keeps it optically centred throughout.
                .offset(x: tracking / 2)
        }
        .onAppear { start = Date() }
        .accessibilityLabel(text)
    }

    private var display: Text {
        Text(uppercase ? text.uppercased() : text)
            .font(.system(size: fontSize, weight: .semibold))
            .foregroundStyle(color)
    }

    private func state(at date: Date) -> (tracking: CGFloat, opacity: Double) {
        if reduceMotion { return (minTracking, 1) }
        if let phase { return breathe(min(max(phase, 0), 1)) }

        // Entrance: tracking starts flared out and settles in, once, before
        // the continuous breath takes over. Kept to one fading `Text` — not
        // a per-character stagger — so kerning stays intact through the
        // entrance too, which is the whole point of this approach.
        let elapsedMs = date.timeIntervalSince(start) * 1000
        if elapsedMs < entranceDuration {
            let eased = easeOutQuint(elapsedMs / entranceDuration)
            let flared = maxTracking + 8
            let tracking = flared + (minTracking - flared) * CGFloat(eased)
            return (tracking, eased)
        }

        let periodMs = max(period * 1000, 1)
        let cycle = ((elapsedMs - entranceDuration) / periodMs)
            .truncatingRemainder(dividingBy: 1)
        return breathe(cycle)
    }

    /// Raised-cosine: 0 → 1 → 0 across one period, which *is* an
    /// ease-in-out — the derivative is zero at both the min and the max, so
    /// the breath never has a corner.
    private func breathe(_ phase: Double) -> (tracking: CGFloat, opacity: Double) {
        let wave = (1 - cos(2 * Double.pi * phase)) / 2
        let tracking = minTracking + (maxTracking - minTracking) * CGFloat(wave)
        // Inversely coupled at 2% amplitude: wider tracking, slightly
        // lighter — the detail that reads as breathing rather than just
        // stretching.
        let opacity = 1 - 0.02 * wave
        return (tracking, opacity)
    }

    private func easeOutQuint(_ t: Double) -> Double {
        let clamped = min(max(t, 0), 1)
        return 1 - pow(1 - clamped, 5)
    }
}

#Preview {
    TrackingBreathe()
        .padding(30)
}
iOS 17 · No dependencies

SwiftUI note. Deliberately one Text with an animated .tracking(), not an HStack of characters with animated spacing — the HStack route is animatable via plain withAnimation, but it throws away kerning for nothing this effect needs. The one-time entrance fades and settles as a single block rather than per character, for the same reason.

Dependencies

SwiftUI
No external dependencies

Requires iOS 17

Flutter
No external dependencies

Requires Flutter 3.27