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

Scramble Text

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

Characters churn through random glyphs and lock into the real word.

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

  • text
  • scramble
  • decrypt
  • glitch
  • monospace
  • terminal
  • animation

The actual source

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

import SwiftUI

/// The glyph pool an unlocked character re-rolls from.
enum ScrambleCharset: Sendable {
    case alphanumeric
    case uppercase
    case symbols
    case hex
    case binary
    case katakana

    var characters: [Character] {
        switch self {
        case .alphanumeric:
            return Array("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789")
        case .uppercase:
            return Array("ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789")
        case .symbols:
            return Array("!@#$%^&*()-_=+[]{}<>/?")
        case .hex:
            return Array("0123456789ABCDEF")
        case .binary:
            return Array("01")
        case .katakana:
            return Array("アイウエオカキクケコサシスセソタチツテトナニヌネノハヒフヘホマミムメモヤユヨラリルレロワヲン")
        }
    }
}

/// Characters churn through random glyphs and lock into the real word, left
/// to right.
///
/// The rendered *string* changes every frame, so this is a
/// `TimelineView(.animation)` component — `withAnimation` cannot interpolate
/// text. It renders as a single `Text` over one `AttributedString` with a
/// per-character colour run for the lock flash, rather than one view per
/// character: that keeps the line monospaced and un-exploded, and it is the
/// only way to get the flash colour and the glyph substitution into the same
/// pass. Every roll is a deterministic hash of `(characterIndex, rollBucket)`
/// rather than `Random()`, so the preview renderer samples the same frames
/// on every run.
struct ScrambleText: View {
    var text: String = "ACCESS GRANTED"
    var charset: ScrambleCharset = .alphanumeric
    /// Milliseconds per character until it locks, left to right.
    var lockSpeed: Double = 45
    /// How often an unlocked character re-rolls its glyph.
    var rollInterval: Double = 60
    var accent: Color = Color(red: 0.208, green: 1.0, blue: 0.620)
    var flash: Bool = true
    var fontSize: CGFloat = 28
    var color: Color = Color(white: 0.08)

    /// Preview override. When set, the line renders that point of one
    /// complete decrypt instead of running its own clock.
    var progress: Double? = nil

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

    var body: some View {
        Group {
            if let progress {
                line(elapsedMs: progress * totalDurationMs)
            } else if reduceMotion {
                line(elapsedMs: totalDurationMs)
            } else {
                TimelineView(.animation(paused: settled)) { timeline in
                    let elapsed = startDate.map { timeline.date.timeIntervalSince($0) * 1000 } ?? 0
                    line(elapsedMs: elapsed)
                }
            }
        }
        .onAppear { if startDate == nil { startDate = Date() } }
        // Otherwise VoiceOver spells out every intermediate roll.
        .accessibilityElement(children: .ignore)
        .accessibilityLabel(text)
    }

    /// Stops the timeline once the whole line has locked and the flash tail
    /// has finished, instead of redrawing a settled string forever.
    private var settled: Bool {
        guard let startDate else { return false }
        return Date().timeIntervalSince(startDate) * 1000 > totalDurationMs + 200
    }

    /// Last character's lock time plus the 400ms hold the spec calls for.
    private var totalDurationMs: Double {
        Double(text.count) * lockSpeed + 400
    }

    private func line(elapsedMs: Double) -> some View {
        var attributed = AttributedString()
        let sequence = charset.characters
        let bucket = Int(elapsedMs / max(rollInterval, 1))

        for (index, character) in text.enumerated() {
            if character == " " {
                attributed += AttributedString(" ")
                continue
            }

            let lockTime = Double(index + 1) * lockSpeed
            var run: AttributedString
            var runColor = color

            if elapsedMs >= lockTime {
                run = AttributedString(String(character))
                if flash {
                    let flashT = min(max((elapsedMs - lockTime) / 90, 0), 1)
                    runColor = blend(accent, color, t: easeOut(flashT))
                }
            } else {
                let glyphIndex = deterministicHash(index, bucket, modulo: sequence.count)
                run = AttributedString(String(sequence[glyphIndex]))
            }

            run.foregroundColor = runColor
            attributed += run
        }

        return Text(attributed)
            .font(.system(size: fontSize, weight: .semibold, design: .monospaced))
    }

    private func easeOut(_ t: Double) -> Double { 1 - (1 - t) * (1 - t) }

    /// `Color.resolve(in:)` (iOS 17/macOS 14) is the portable way to read
    /// channel values without reaching for `UIColor`, which has no `NSColor`
    /// equivalent.
    private func blend(_ from: Color, _ to: Color, t: Double) -> Color {
        let a = from.resolve(in: environment)
        let b = to.resolve(in: environment)
        return Color(
            red: Double(a.red) + (Double(b.red) - Double(a.red)) * t,
            green: Double(a.green) + (Double(b.green) - Double(a.green)) * t,
            blue: Double(a.blue) + (Double(b.blue) - Double(a.blue)) * t
        )
    }

    /// MurmurHash3-style finalizer over `(index, bucket)`. Deterministic and
    /// well distributed, with no dependency on a seeded RNG.
    private func deterministicHash(_ index: Int, _ bucket: Int, modulo: Int) -> Int {
        guard modulo > 0 else { return 0 }
        var x = UInt64(bitPattern: Int64(index &* 374_761_393 &+ bucket &* 668_265_263))
        x ^= x >> 33
        x = x &* 0xff51_afd7_ed55_8ccd
        x ^= x >> 33
        x = x &* 0xc4ce_b9fe_1a85_ec53
        x ^= x >> 33
        return Int(x % UInt64(modulo))
    }
}

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

SwiftUI note. Monospaced is load-bearing, not a style choice — it is what stops the line reflowing on every roll. Rendered as one `AttributedString` rather than per-character views, so it stays a single `Text` layout pass.

Dependencies

SwiftUI
No external dependencies

Requires iOS 17

Flutter
No external dependencies

Requires Flutter 3.27