
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
- Flutter
- No external dependencies
Requires iOS 17
Requires Flutter 3.27


