Typewriter Text
Text that types itself one character at a time, followed by a blinking cursor.

- Text
- iOS 18.0+
- intermediate
- Origin
- Original work
- Found by
- typewriter · typing · cursor · blinking · text animation · terminal · characters · máquina de escribir · escribir · cursor · parpadeo · animación de texto · terminal · letras
Description
Section titled “Description”Types its text one character at a time, followed by a blinking cursor.
It restarts when text changes. With Reduce Motion on, the text appears in full with no cursor.
struct TypewriterTextDemo: View { @State private var index = 0
private let lines = [ "$ swift build\nCompiling your ideas...", "$ swift test\nAll tests passed, no bugs today.", "$ git push\nShipped before lunch.", ]
var body: some View { ZStack { Color.black.ignoresSafeArea()
VStack(alignment: .leading, spacing: 16) { HStack(spacing: 8) { ForEach([Color.red, .yellow, .green], id: \.self) { color in Circle().fill(color).frame(width: 12, height: 12) } } .accessibilityHidden(true)
TypewriterText(text: lines[index], cursorColor: .green) .font(.system(.title2, design: .monospaced, weight: .medium)) .foregroundStyle(.green) .frame(maxWidth: .infinity, minHeight: 160, alignment: .topLeading) } .padding(24) .background(.white.opacity(0.08), in: .rect(cornerRadius: 20)) .padding(20) } .task { while !Task.isCancelled { try? await Task.sleep(for: .seconds(3.2)) index = (index + 1) % lines.count } } }}API reference
Section titled “API reference”| Parameter | Type | Default |
|---|---|---|
text | String | Required |
charactersPerSecond | Double | 20 |
cursorColor | Color | .accentColor |
Copy this code into your project, or download the file.
// SPDX-License-Identifier: MIT// Copyright (c) 2026 Oscar Canton and SwiftUI Blocks contributors
import SwiftUI
/// Types its text one character at a time, followed by a blinking cursor.////// It restarts when `text` changes. With Reduce Motion on, the text appears in full with no cursor.struct TypewriterText: View { let text: String var charactersPerSecond: Double = 20 var cursorColor: Color = .accentColor
@State private var started: (text: String, date: Date)? @Environment(\.accessibilityReduceMotion) private var reduceMotion
var body: some View { TimelineView(.animation(minimumInterval: 1.0 / 30.0, paused: reduceMotion)) { context in Text(typed(at: context.date)) } .accessibilityElement(children: .ignore) .accessibilityLabel(text) .task(id: text) { started = (text, .now) } }
/// The characters not typed yet stay in the string as clear text, so lines wrap once and never jump. private func typed(at date: Date) -> AttributedString { guard !reduceMotion else { return AttributedString(text) }
let elapsed = started.flatMap { $0.text == text ? date.timeIntervalSince($0.date) : nil } ?? 0 let count = TypewriterTextProgress.visibleCount( elapsed: elapsed, charactersPerSecond: charactersPerSecond, total: text.count ) let isTyping = count < text.count
var cursor = AttributedString("|") cursor.foregroundColor = isTyping || Int(elapsed * 2).isMultiple(of: 2) ? cursorColor : .clear
var remaining = AttributedString(String(text.dropFirst(count))) remaining.foregroundColor = .clear
return AttributedString(String(text.prefix(count))) + cursor + remaining }}
enum TypewriterTextProgress { /// How many characters are visible after `elapsed` seconds, from 0 to `total`. static func visibleCount(elapsed: TimeInterval, charactersPerSecond: Double, total: Int) -> Int { guard total > 0 else { return 0 } guard charactersPerSecond > 0 else { return total }
let typed = (elapsed * charactersPerSecond).rounded(.down) guard typed > 0 else { return 0 } return typed >= Double(total) ? total : Int(typed) }}
// MARK: - Demo
struct TypewriterTextDemo: View { @State private var index = 0
private let lines = [ "$ swift build\nCompiling your ideas...", "$ swift test\nAll tests passed, no bugs today.", "$ git push\nShipped before lunch.", ]
var body: some View { ZStack { Color.black.ignoresSafeArea()
VStack(alignment: .leading, spacing: 16) { HStack(spacing: 8) { ForEach([Color.red, .yellow, .green], id: \.self) { color in Circle().fill(color).frame(width: 12, height: 12) } } .accessibilityHidden(true)
TypewriterText(text: lines[index], cursorColor: .green) .font(.system(.title2, design: .monospaced, weight: .medium)) .foregroundStyle(.green) .frame(maxWidth: .infinity, minHeight: 160, alignment: .topLeading) } .padding(24) .background(.white.opacity(0.08), in: .rect(cornerRadius: 20)) .padding(20) } .task { while !Task.isCancelled { try? await Task.sleep(for: .seconds(3.2)) index = (index + 1) % lines.count } } }}
#Preview { TypewriterTextDemo()}