Skip to content
Split Text preview
An animated render of the SwiftUI source on this page.

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

Requires iOS 17

Flutter
No external dependencies

Requires Flutter 3.27