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

Typewriter Text

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

Text that types itself, holds, deletes, and moves to the next phrase.

Text Effects · SwiftUI · Flutter · iOS 17 · Flutter 3.16

  • text
  • typewriter
  • typing
  • headline
  • animation
  • hero

The actual source

TypewriterText.swift
// Typewriter Text · syxUI · https://syxui.dev/components/typewriter-text
// Free in any project. Keep this line and credit syxUI where a person can read it.

import SwiftUI

/// Text that types itself out, holds, deletes, and moves to the next phrase.
///
/// Driven by one looping value rather than a chain of timers, so there is
/// nothing to cancel and the cycle stays in step after a backgrounding.
struct TypewriterText: View {
    var phrases: [String] = ["ships SwiftUI.", "ships Flutter.", "ships both."]
    var prefix: String = "syxUI "
    var font: Font = .system(size: 26, weight: .semibold)
    var caret: Color = Color(red: 0.15, green: 0.45, blue: 1.0)
    /// Seconds for one phrase: type, hold, delete.
    var cycle: Double = 3.4
    var phase: Double? = nil

    @Environment(\.accessibilityReduceMotion) private var reduceMotion
    @State private var animated: Double = 0

    private var current: Double { phase ?? animated }

    var body: some View {
        HStack(spacing: 0) {
            Text(prefix).foregroundStyle(.primary)
            Text(visibleText).foregroundStyle(.primary)
            Rectangle()
                .fill(caret)
                .frame(width: 2.5, height: 26)
                .opacity(caretOpacity)
                .padding(.leading, 2)
        }
        .font(font)
        .monospacedDigit()
        .onAppear(perform: start)
        // VoiceOver should hear the sentence, not a stream of partial words.
        .accessibilityElement()
        .accessibilityLabel(prefix + (phrases.first ?? ""))
    }

    /// Splits the loop into per-phrase slots, then each slot into
    /// type (0–0.45), hold (0.45–0.72) and delete (0.72–1).
    private var visibleText: String {
        guard !phrases.isEmpty else { return "" }

        let total = current * Double(phrases.count)
        let index = min(Int(total), phrases.count - 1)
        let local = total - Double(index)
        let phrase = Array(phrases[index])

        let revealed: Double
        switch local {
        case ..<0.45: revealed = local / 0.45
        case ..<0.72: revealed = 1
        default: revealed = 1 - (local - 0.72) / 0.28
        }

        let count = Int((Double(phrase.count) * max(revealed, 0)).rounded())
        return String(phrase.prefix(count))
    }

    private var caretOpacity: Double {
        guard !reduceMotion else { return 1 }
        // Two blinks per phrase slot.
        return sin(current * Double(phrases.count) * 4 * .pi) > 0 ? 1 : 0.15
    }

    private func start() {
        guard phase == nil, !reduceMotion else { return }
        withAnimation(
            .linear(duration: cycle * Double(phrases.count)).repeatForever(autoreverses: false)
        ) {
            animated = 1
        }
    }
}

#Preview {
    TypewriterText()
        .padding(30)
}
iOS 17 · No dependencies

SwiftUI note. One repeating animation drives typing, the caret blink, and the phrase change, so there are no timers to invalidate. VoiceOver reads the finished sentence rather than each partial word.

Dependencies

SwiftUI
No external dependencies

Requires iOS 17

Flutter
No external dependencies

Requires Flutter 3.16