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

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

Requires iOS 17

Flutter
No external dependencies

Requires Flutter 3.27