Skip to content
Flip Words preview
An animated render of the SwiftUI source on this page.

Flip Words

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

One word swaps for the next while the sentence springs to fit it.

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

  • text
  • words
  • rotator
  • swap
  • hero
  • headline
  • animation

The actual source

FlipWords.swift
// Flip Words · syxUI · https://syxui.dev/components/flip-words
// Free in any project. Keep this line and credit syxUI where a person can read it.

import SwiftUI

/// One word swaps for the next while the sentence springs to fit it.
///
/// Unlike a fixed-width roll, the container width itself tracks the incoming
/// word's measured width, so everything after it visibly reflows — that
/// reflow is the whole point of this component, as opposed to a fixed-width
/// roller. The whole timeline (hold, exit, gap, enter, per-character stagger)
/// is one pure function of elapsed time sampled by `TimelineView(.animation)`,
/// so a scrubbed `phase` renders the exact same frame a live clock would.
struct FlipWords: View {
    var prefix: String = "Design for"
    /// Comma-separated words to rotate through.
    var words: String = "iOS, Flutter, everyone"
    var hold: Double = 1900
    /// Per-character entrance stagger, in milliseconds.
    var stagger: Double = 24
    var accent: Color = Color(red: 0.059, green: 0.384, blue: 0.996)
    var damping: Double = 0.75
    /// Preview override. When set, the component renders that point of one
    /// loop through all words instead of driving its own clock.
    var phase: Double? = nil

    @Environment(\.accessibilityReduceMotion) private var reduceMotion
    @State private var widths: [Int: CGFloat] = [:]

    private let exitMs: Double = 260
    private let gapMs: Double = 60
    private let enterMs: Double = 380

    private var wordList: [String] {
        let pieces = words.split(separator: ",").map { $0.trimmingCharacters(in: .whitespaces) }
        return pieces.isEmpty ? [""] : pieces
    }

    /// The enter window stretches to fit the longest word's full stagger
    /// cascade, so every word — not just the shortest — finishes settling
    /// before the next hold begins. Kept uniform across words so the loop
    /// period stays constant.
    private var enterWindow: Double {
        let longest = wordList.map(\.count).max() ?? 1
        return enterMs + Double(max(longest - 1, 0)) * stagger
    }

    private var slotMs: Double { hold + exitMs + gapMs + enterWindow }
    private var loopMs: Double { slotMs * Double(wordList.count) }

    var body: some View {
        Group {
            if reduceMotion && phase == nil {
                frame(atMs: 0)
            } else {
                TimelineView(.animation) { context in
                    let ms = phase.map { $0 * loopMs }
                        ?? (context.date.timeIntervalSinceReferenceDate * 1000)
                        .truncatingRemainder(dividingBy: loopMs)
                    frame(atMs: ms)
                }
            }
        }
        .accessibilityElement(children: .ignore)
        .accessibilityLabel(prefix + " " + (wordList.first ?? ""))
    }

    private func frame(atMs ms: Double) -> some View {
        let clamped = ms.truncatingRemainder(dividingBy: loopMs)
        let positive = clamped < 0 ? clamped + loopMs : clamped
        let slot = Int(positive / slotMs) % wordList.count
        let localMs = positive.truncatingRemainder(dividingBy: slotMs)

        let t1 = hold
        let t2 = t1 + exitMs
        let t3 = t2 + gapMs

        let displayIndex: Int
        let settle: Double
        let entering: Bool

        if localMs < t1 {
            displayIndex = slot
            settle = 1
            entering = false
        } else if localMs < t2 {
            displayIndex = slot
            settle = 1 - easeIn((localMs - t1) / exitMs)
            entering = false
        } else if localMs < t3 {
            displayIndex = slot
            settle = 0
            entering = false
        } else {
            displayIndex = (slot + 1) % wordList.count
            settle = ((localMs - t3) / enterWindow).clamped(to: 0...1)
            entering = true
        }

        return HStack(spacing: 8) {
            Text(prefix)
            wordView(index: displayIndex, settle: settle, entering: entering)
        }
    }

    private func wordView(index: Int, settle: Double, entering: Bool) -> some View {
        let word = wordList[index]
        let chars = Array(word.enumerated())

        return HStack(spacing: 0) {
            ForEach(chars, id: \.offset) { i, ch in
                let charSettle = entering
                    ? staggeredEnter(overallSettle: settle, charIndex: i)
                    : settle
                let hide = 1 - charSettle

                Text(String(ch))
                    .blur(radius: 7 * max(hide, 0))
                    .opacity(charSettle.clamped(to: 0...1))
                    .offset(y: -14 * hide)
                    .scaleEffect(1 - 0.06 * hide)
            }
        }
        .foregroundStyle(accent)
        .fixedSize()
        .background {
            GeometryReader { g in
                Color.clear.onAppear { widths[index] = g.size.width }
            }
        }
        .frame(width: widths[index], alignment: .leading)
    }

    /// Shifts the spring's time input by this character's stagger delay, so
    /// each glyph runs its own copy of the same curve rather than scaling
    /// one shared output — that is what makes the word assemble left to
    /// right instead of fading in as a block.
    private func staggeredEnter(overallSettle settle: Double, charIndex i: Int) -> Double {
        let elapsedMs = settle * enterWindow
        let charElapsed = elapsedMs - Double(i) * stagger
        guard charElapsed > 0 else { return 0 }
        return springEase((charElapsed / enterMs).clamped(to: 0...1))
    }

    private func easeIn(_ t: Double) -> Double { t * t }

    /// Closed-form damped-oscillator step response — a deterministic stand-in
    /// for `.spring(response:dampingFraction:)` so the curve is exactly
    /// reproducible from a static `phase`/`progress`, not just a live
    /// animation. `damping` is the param; the natural frequency is fixed to
    /// keep the settle inside one normalised `enterMs` window.
    private func springEase(_ t: Double) -> Double {
        guard t > 0 else { return 0 }
        guard t < 1 else { return 1 }
        let zeta = damping.clamped(to: 0.05...0.98)
        let omega = 8.5
        let omegaD = omega * sqrt(max(1 - zeta * zeta, 0.0001))
        let envelope = exp(-zeta * omega * t)
        return 1 - envelope * (cos(omegaD * t) + (zeta * omega / omegaD) * sin(omegaD * t))
    }
}

private extension Comparable {
    func clamped(to range: ClosedRange<Self>) -> Self {
        min(max(self, range.lowerBound), range.upperBound)
    }
}

#Preview {
    FlipWords()
        .font(.system(size: 30, weight: .semibold))
        .frame(width: 340, height: 100)
}
iOS 17 · No dependencies

SwiftUI note. The container width is measured per word via a hidden `GeometryReader` background and cached, since iOS 17 has no `matchedGeometryEffect`-free shortcut for it — `matchedGeometryEffect` itself is skipped because it fights the per-character stagger. The whole timeline is a closed-form function of elapsed time so a scrubbed `phase` never depends on a live `.spring()` having run.

Dependencies

SwiftUI
No external dependencies

Requires iOS 17

Flutter
No external dependencies

Requires Flutter 3.27