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


