Skip to content

Typewriter Text

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

Typewriter Text, playing
  • Text
  • iOS 18.0+
  • intermediate
Download .swift
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

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
}
}
}
}
ParameterTypeDefault
textStringRequired
charactersPerSecondDouble20
cursorColorColor.accentColor

Copy this code into your project, or download the file.

TypewriterText.swift
// 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()
}