
Text Reveal
From syxUI — written for both platforms, not translated between them.
Lines of type uncovered by a wipe, with a bright bar riding the edge.
Text Effects · SwiftUI · Flutter · iOS 17 · Flutter 3.27
- text
- reveal
- mask
- wipe
- editorial
- headline
- animation
The actual source
TextRevealMask.swift
// Text Reveal · syxUI · https://syxui.dev/components/text-reveal-mask
// Free in any project. Keep this line and credit syxUI where a person can read it.
import SwiftUI
/// Which edge the reveal window grows from, and therefore which way its
/// leading edge travels.
enum WipeDirection {
case up, down, left, right
}
/// Lines of type uncovered by a wipe, with a bright bar riding the edge.
///
/// Each line has its own reveal window inside one overall timeline: the
/// windows overlap by `lineStagger`, so line two starts before line one
/// finishes. Within a line's window the growth itself is eased
/// (`easeInOutCubic`) even though the master clock advances linearly — that
/// split is what keeps per-line timing correct while still allowing a single
/// `progress` to scrub the whole thing for the preview renderer.
struct TextRevealMask: View {
var text: String = "Type that\nreveals itself"
var direction: WipeDirection = .up
/// Delay before the next line starts, in ms.
var lineStagger: Double = 120
/// Per-line wipe duration, in ms.
var duration: Double = 640
var barColor: Color = Color(red: 0.059, green: 0.384, blue: 0.996)
var showBar: Bool = true
/// Preview override. When set, renders that point of the one-shot reveal.
var progress: Double? = nil
@Environment(\.accessibilityReduceMotion) private var reduceMotion
@State private var animated: Double = 0
private var current: Double { progress ?? animated }
private var lines: [String] { text.components(separatedBy: "\n") }
/// Total timeline length in seconds: the last line's window end.
private var totalSeconds: Double {
(duration + lineStagger * Double(max(lines.count - 1, 0))) / 1000
}
var body: some View {
VStack(alignment: .leading, spacing: 8) {
ForEach(Array(lines.enumerated()), id: \.offset) { index, line in
lineView(line, index: index)
}
}
.onAppear(perform: start)
.accessibilityElement(children: .ignore)
.accessibilityLabel(text.replacingOccurrences(of: "\n", with: ", "))
}
private func lineView(_ line: String, index: Int) -> some View {
let total = totalSeconds
let start = (lineStagger * Double(index)) / 1000 / max(total, 0.0001)
let span = (duration / 1000) / max(total, 0.0001)
let raw = reduceMotion ? 1.0 : min(max((current - start) / max(span, 0.0001), 0), 1)
let a = Self.easeInOutCubic(raw)
let vertical = direction == .up || direction == .down
return Text(line)
.font(.system(size: 34, weight: .semibold))
.fixedSize()
.mask(alignment: maskAlignment) {
GeometryReader { g in
Rectangle()
.frame(
width: vertical ? g.size.width : g.size.width * a,
height: vertical ? g.size.height * a : g.size.height
)
}
}
.overlay(alignment: .topLeading) {
if showBar && !reduceMotion {
GeometryReader { g in
edgeBar(a: a, size: g.size, vertical: vertical)
}
}
}
}
private var maskAlignment: Alignment {
switch direction {
case .up: return .bottom // grows upward off the baseline
case .down: return .top // grows downward from the cap line
case .left: return .trailing // grows leftward
case .right: return .leading // grows rightward
}
}
private func edgeBar(a: Double, size: CGSize, vertical: Bool) -> some View {
let thickness: CGFloat = 2.5
let edge: CGFloat = {
switch direction {
case .up: return size.height * (1 - a)
case .down: return size.height * a
case .left: return size.width * (1 - a)
case .right: return size.width * a
}
}()
// Bar rides the moving edge and fades out over the final quarter of
// the line's own window; hidden before it starts and once settled.
let opacity: Double = (a <= 0 || a >= 1) ? 0 : (a > 0.75 ? (1 - a) / 0.25 : 1)
return Capsule()
.fill(barColor)
.frame(
width: vertical ? size.width : thickness,
height: vertical ? thickness : size.height
)
.position(
x: vertical ? size.width / 2 : edge,
y: vertical ? edge : size.height / 2
)
.opacity(opacity)
}
private func start() {
guard progress == nil, !reduceMotion else { return }
withAnimation(.linear(duration: totalSeconds)) {
animated = 1
}
}
private static func easeInOutCubic(_ x: Double) -> Double {
x < 0.5 ? 4 * x * x * x : 1 - pow(-2 * x + 2, 3) / 2
}
}
#Preview {
TextRevealMask()
.frame(width: 380, height: 240)
.padding(24)
}
iOS 17 · No dependencies
SwiftUI note. Lines are pre-broken on `\n` rather than measured, which is what keeps this iOS-17-safe. On iOS 18, a TextRenderer could mask real wrapped lines from Text.Layout without asking the caller to pre-break them.
Dependencies
- SwiftUI
- No external dependencies
- Flutter
- No external dependencies
Requires iOS 17
Requires Flutter 3.27


