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


