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

Terminal Text

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

A console log that types itself out, line by line, behind a block caret.

Text Effects · SwiftUI · Flutter · iOS 17 · Flutter 3.27

  • text
  • terminal
  • console
  • typing
  • monospace
  • caret
  • animation

The actual source

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

import SwiftUI

/// The caret shape drawn after the active line's visible text.
enum TerminalCaretStyle: Sendable {
    case block
    case bar
    case underscore
}

/// A console log that types itself out, line by line, behind a block caret.
///
/// The rendered *string* grows every frame, so this is a
/// `TimelineView(.animation)` component, never `withAnimation`. Lines type
/// sequentially; even-indexed lines are treated as commands and get the
/// prompt gutter, odd-indexed lines are their output and do not — matching
/// the shape of a real shell transcript. The caret holds solid while a line
/// is actively typing and only blinks once typing pauses, which is what
/// separates a terminal from a typewriter.
struct TerminalText: View {
    var lines: String = "npm i syxui\n✓ 0 dependencies\nswift build\n✓ Build complete"
    /// Milliseconds per character.
    var typeSpeed: Double = 22
    var prompt: String = "$ "
    var accent: Color = Color(red: 0.208, green: 1.0, blue: 0.620)
    var caretStyle: TerminalCaretStyle = .block
    var background: Color = Color(red: 0.043, green: 0.059, blue: 0.078)
    var fontSize: CGFloat = 17
    var textColor: Color = Color(white: 0.92)

    /// Preview override. When set, the console renders that point of the
    /// whole log typing out once instead of running its own clock.
    var progress: Double? = nil

    @Environment(\.accessibilityReduceMotion) private var reduceMotion
    @State private var startDate: Date? = nil

    private var lineList: [String] {
        lines.split(separator: "\n", omittingEmptySubsequences: false).map(String.init)
    }

    var body: some View {
        Group {
            if let progress {
                console(elapsedMs: progress * totalDurationMs)
            } else if reduceMotion {
                console(elapsedMs: totalDurationMs)
            } else {
                // No `paused:` here — unlike a one-shot reveal, a terminal
                // caret keeps blinking forever once typing finishes.
                TimelineView(.animation) { timeline in
                    let elapsed = startDate.map { timeline.date.timeIntervalSince($0) * 1000 } ?? 0
                    console(elapsedMs: elapsed)
                }
            }
        }
        .onAppear { if startDate == nil { startDate = Date() } }
        .padding(16)
        .background(background)
        // The finished log is what matters to VoiceOver, not every
        // intermediate keystroke.
        .accessibilityElement(children: .ignore)
        .accessibilityLabel(lineList.joined(separator: ". "))
    }

    private struct LineTiming {
        var start: Double
        var end: Double
        var isCommand: Bool
    }

    /// Start/end time for every line: a 240ms hold after each line, plus a
    /// 90ms beat before a fresh prompt appears.
    private var timings: [LineTiming] {
        var out: [LineTiming] = []
        var cursor = 0.0
        for (index, line) in lineList.enumerated() {
            let isCommand = index.isMultiple(of: 2)
            if index > 0 && isCommand { cursor += 90 }
            let end = cursor + Double(line.count) * typeSpeed
            out.append(LineTiming(start: cursor, end: end, isCommand: isCommand))
            cursor = end + 240
        }
        return out
    }

    private var totalDurationMs: Double { timings.last?.end ?? 0 }

    private func console(elapsedMs: Double) -> some View {
        let allTimings = timings
        let activeIndex = allTimings.lastIndex { $0.start <= elapsedMs } ?? 0
        let isTypingNow = elapsedMs < allTimings[activeIndex].end

        return VStack(alignment: .leading, spacing: 4) {
            ForEach(0...activeIndex, id: \.self) { index in
                lineView(
                    index: index, elapsedMs: elapsedMs,
                    isActive: index == activeIndex,
                    isTypingNow: index == activeIndex && isTypingNow
                )
            }
        }
        .font(.system(size: fontSize, design: .monospaced))
    }

    private func lineView(index: Int, elapsedMs: Double, isActive: Bool, isTypingNow: Bool) -> some View {
        let timing = timings[index]
        let full = lineList[index]
        let visibleCount = isActive
            ? min(full.count, max(0, Int((elapsedMs - timing.start) / max(typeSpeed, 1))))
            : full.count
        let visible = String(full.prefix(visibleCount))

        return HStack(alignment: .lastTextBaseline, spacing: 2) {
            if timing.isCommand {
                Text(prompt).foregroundStyle(accent)
            }
            Text(visible).foregroundStyle(textColor)
            if isActive {
                caret(solid: isTypingNow, elapsedMs: elapsedMs)
            }
        }
        // Dim everything except the line the eye should be on.
        .opacity(isActive ? 1.0 : 0.72)
    }

    private func caret(solid: Bool, elapsedMs: Double) -> some View {
        let blinkOn = Int(elapsedMs / 530).isMultiple(of: 2)
        let width = caretStyle == .bar ? fontSize * 0.08 : fontSize * 0.6
        let height = caretStyle == .underscore ? fontSize * 0.1 : fontSize * 1.15

        return RoundedRectangle(cornerRadius: 1)
            .fill(accent)
            .frame(width: width, height: height)
            .opacity(solid || blinkOn ? 1 : 0)
    }
}

#Preview {
    TerminalText()
        .padding(24)
}
iOS 17 · No dependencies

SwiftUI note. Even-indexed lines get the prompt gutter, odd-indexed lines are treated as their output — write `lines` in that alternating shape. The caret keeps blinking on real elapsed time after typing finishes, so its preview override is deliberately left uncapped past `progress: 1`.

Dependencies

SwiftUI
No external dependencies

Requires iOS 17

Flutter
No external dependencies

Requires Flutter 3.27