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


