
Split Text
From syxUI — written for both platforms, not translated between them.
A headline whose characters spring up one after another into place.
Text Effects · SwiftUI · Flutter · iOS 17 · Flutter 3.27
- text
- split
- stagger
- spring
- headline
- reveal
- animation
The actual source
SplitText.swift
// Split Text · syxUI · https://syxui.dev/components/split-text
// Free in any project. Keep this line and credit syxUI where a person can read it.
import SwiftUI
/// Which character takes its turn first.
enum SplitTextOrder: String, CaseIterable, Sendable {
case forward
case reverse
/// Opens outward from the middle of the line.
case centre
/// Seeded, so the same string always shuffles the same way.
case random
}
/// A headline whose characters spring up one after another into place.
///
/// Leave `progress` nil and the reveal runs once on appear. Pass a value and
/// you drive it yourself — a scroll offset, a drag, or one fixed frame for a
/// screenshot.
struct SplitText: View {
var text: String = "Beautifully native"
var size: CGFloat = 34
var color: Color = Color(white: 0.07)
/// Milliseconds between one character starting and the next.
var stagger: Double = 28
/// How far below its resting place a character starts.
var distance: CGFloat = 26
/// 1 lands dead. 0.7 gives a single visible ~4% overshoot and no ringing.
var damping: Double = 0.7
var direction: SplitTextOrder = .forward
/// Preview override. When set, the line renders that point of the reveal
/// instead of driving its own clock.
var progress: Double? = nil
/// How long one character takes to travel, in milliseconds.
var window: Double = 520
@Environment(\.accessibilityReduceMotion) private var reduceMotion
@State private var start = Date()
@State private var settled = false
var body: some View {
let order = inkOrder
let runtime = max(stagger * Double(order.max() ?? 0) + window, 1)
// Paused once the last character has landed: a one-shot has no reason
// to keep a display link alive for the rest of the screen's life.
TimelineView(.animation(paused: isPaused)) { context in
line(order: order, runtime: runtime, at: fraction(runtime: runtime, now: context.date))
}
.onAppear {
start = Date()
settled = false
}
.task(id: text) {
guard progress == nil, !reduceMotion else { return }
try? await Task.sleep(for: .milliseconds(Int(runtime) + 60))
settled = true
}
// One label for the whole phrase — VoiceOver must not spell it out
// letter by letter just because the effect needs one view per glyph.
.accessibilityElement(children: .ignore)
.accessibilityLabel(text)
}
private var isPaused: Bool { progress != nil || reduceMotion || settled }
private func fraction(runtime: Double, now: Date) -> Double {
if let progress { return min(max(progress, 0), 1) }
// Reduce Motion lands on the finished line rather than a frozen frame.
if reduceMotion || settled { return 1 }
return min(max(now.timeIntervalSince(start) * 1000 / runtime, 0), 1)
}
private func line(order: [Int], runtime: Double, at fraction: Double) -> some View {
let tokens = tokens(order: order)
// Words, not characters, are what the layout breaks on: each token
// keeps its own trailing space, so a wrap lands between words exactly
// where real text layout would put it.
return SplitFlowLayout(spacing: 0, lineSpacing: size * 0.22) {
ForEach(tokens.indices, id: \.self) { index in
HStack(spacing: 0) {
ForEach(tokens[index]) { glyph in
character(glyph, runtime: runtime, fraction: fraction)
}
}
}
}
}
@ViewBuilder
private func character(_ glyph: SplitGlyph, runtime: Double, fraction: Double) -> some View {
let body = Text(String(glyph.character))
.font(.system(size: size, weight: .bold))
.foregroundStyle(color)
if glyph.slot < 0 {
// A space carries no ink, so animating it would spend a view on
// nothing visible.
body
} else {
let local = min(max((fraction * runtime - stagger * Double(glyph.slot)) / window, 0), 1)
let settle = spring(local)
body
// Opacity runs ahead of the travel on purpose: the character is
// readable while it is still moving, which is what makes the
// line feel like it is arriving rather than fading in.
.opacity(min(local * 2.4, 1))
.scaleEffect(CGFloat(0.92 + 0.08 * settle), anchor: .bottom)
.offset(y: distance * CGFloat(1 - settle))
}
}
/// Closed-form damped spring, normalised so `u = 1` is fully settled.
///
/// `withAnimation(.spring())` cannot drive this. SwiftUI interpolates the
/// *result* of a state change, and a per-character window is a non-linear
/// function of the clock — animated that way, every character would move at
/// once and the stagger would disappear. Evaluating the spring per frame
/// keeps each character on its own timeline, and gives the Flutter build
/// something it can match exactly.
private func spring(_ u: Double) -> Double {
guard u > 0 else { return 0 }
guard u < 1 else { return 1 }
let zeta = min(max(damping, 0.05), 1)
let omega = 9.0 // settles inside `window` at any damping
if zeta >= 1 {
return 1 - exp(-omega * u) * (1 + omega * u)
}
let damped = omega * (1 - zeta * zeta).squareRoot()
return 1 - exp(-zeta * omega * u)
* (cos(damped * u) + zeta * omega / damped * sin(damped * u))
}
/// One slot per inked character, in the order they should fire.
private var inkOrder: [Int] {
let count = text.reduce(0) { $1.isWhitespace ? $0 : $0 + 1 }
guard count > 0 else { return [] }
switch direction {
case .forward:
return Array(0..<count)
case .reverse:
return (0..<count).map { count - 1 - $0 }
case .centre:
let middle = Double(count - 1) / 2
return (0..<count).map { Int(abs(Double($0) - middle).rounded()) }
case .random:
var slots = Array(0..<count)
// Fisher-Yates from a fixed seed, so a screenshot of the same
// headline is reproducible frame for frame.
var state: UInt64 = 0x9E37_79B9_7F4A_7C15
for index in stride(from: count - 1, to: 0, by: -1) {
state = state &* 6_364_136_223_846_793_005 &+ 1_442_695_040_888_963_407
slots.swapAt(index, Int(state >> 33) % (index + 1))
}
return slots
}
}
/// The line split into word-plus-trailing-space tokens.
private func tokens(order: [Int]) -> [[SplitGlyph]] {
var result: [[SplitGlyph]] = []
var token: [SplitGlyph] = []
var ink = 0
for (index, character) in text.enumerated() {
let space = character.isWhitespace
token.append(
SplitGlyph(
id: index,
character: character,
slot: space ? -1 : (ink < order.count ? order[ink] : 0)
)
)
if !space { ink += 1 }
if space {
result.append(token)
token = []
}
}
if !token.isEmpty { result.append(token) }
return result
}
}
private struct SplitGlyph: Identifiable {
let id: Int
let character: Character
/// Position in the timeline, or -1 for whitespace.
let slot: Int
}
/// Minimal wrapping layout so tokens can animate individually.
/// Copy it along with SplitText — it is the only piece the effect depends on.
private struct SplitFlowLayout: Layout {
var spacing: CGFloat = 0
var lineSpacing: CGFloat = 6
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
let maxWidth = proposal.width ?? .infinity
var x: CGFloat = 0, y: CGFloat = 0, lineHeight: CGFloat = 0, widest: CGFloat = 0
for subview in subviews {
let size = subview.sizeThatFits(.unspecified)
if x > 0 && x + size.width > maxWidth {
y += lineHeight + lineSpacing
x = 0
lineHeight = 0
}
x += size.width + spacing
widest = max(widest, x - spacing)
lineHeight = max(lineHeight, size.height)
}
return CGSize(width: min(widest, maxWidth), height: y + lineHeight)
}
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
var x = bounds.minX, y = bounds.minY, lineHeight: CGFloat = 0
for subview in subviews {
let size = subview.sizeThatFits(.unspecified)
if x > bounds.minX && x + size.width > bounds.maxX {
y += lineHeight + lineSpacing
x = bounds.minX
lineHeight = 0
}
subview.place(at: CGPoint(x: x, y: y), proposal: ProposedViewSize(size))
x += size.width + spacing
lineHeight = max(lineHeight, size.height)
}
}
}
#Preview {
SplitText()
.frame(width: 320, alignment: .leading)
.padding(30)
}
iOS 17 · No dependencies
SwiftUI note. One Text view per character, so kerning, ligatures and complex-script shaping are lost — an acceptable trade for Latin display type at this size, not for body copy. On iOS 18 a TextRenderer walking layout.flatMap(\.self).flatMap(\.self) reproduces the same spring while keeping real shaping.
Dependencies
- SwiftUI
- No external dependencies
- Flutter
- No external dependencies
Requires iOS 17
Requires Flutter 3.27


