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

Scroll Reveal Text

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

A paragraph whose words brighten one by one as it scrolls up the screen.

Text Effects · SwiftUI · Flutter · iOS 17 · Flutter 3.16

  • text
  • scroll
  • reveal
  • paragraph
  • animation
  • editorial

The actual source

ScrollRevealText.swift
// Scroll Reveal Text · syxUI · https://syxui.dev/components/scroll-reveal-text
// Free in any project. Keep this line and credit syxUI where a person can read it.

import SwiftUI

/// A paragraph that resolves word by word as it scrolls up the screen.
///
/// The desktop version of this effect tracks the cursor. On a phone there is
/// no cursor, so scroll position is the input: each word brightens as the
/// block travels through the viewport.
///
/// Drop it inside a `ScrollView` and it wires itself up using the `.scrollView`
/// coordinate space. Pass `progress` to drive it yourself instead.
struct ScrollRevealText: View {
    var text: String = "Components you can read, copy, and own. No package, no build step, no lock-in."
    var font: Font = .system(size: 26, weight: .semibold)
    var dimmed: Color = Color(white: 0.84)
    var solid: Color = Color(white: 0.06)
    var progress: Double? = nil
    /// How many words are mid-reveal at once. Higher reads softer.
    var overlap: Double = 3.0

    private var words: [String] { text.split(separator: " ").map(String.init) }

    var body: some View {
        if let progress {
            layout(progress)
        } else {
            GeometryReader { geo in
                layout(scrollProgress(geo))
            }
            // GeometryReader has no intrinsic height, so reserve the text's.
            .frame(height: estimatedHeight)
        }
    }

    private func layout(_ current: Double) -> some View {
        RevealFlow(spacing: 8, lineSpacing: 8) {
            ForEach(Array(words.enumerated()), id: \.offset) { index, word in
                Text(word)
                    .font(font)
                    .foregroundStyle(dimmed.blended(with: solid, by: reveal(index, current)))
                    .opacity(0.45 + 0.55 * reveal(index, current))
            }
        }
        .accessibilityElement(children: .ignore)
        .accessibilityLabel(text)
    }

    /// 0 when the block sits at the bottom of the viewport, 1 once it has
    /// travelled to the upper third — the range where reading happens.
    private func scrollProgress(_ geo: GeometryProxy) -> Double {
        guard let container = geo.bounds(of: .scrollView(axis: .vertical)) else { return 1 }
        let minY = geo.frame(in: .scrollView(axis: .vertical)).minY - container.minY
        let travel = container.height * 0.75
        return min(max(1 - minY / travel, 0), 1)
    }

    private func reveal(_ index: Int, _ current: Double) -> Double {
        let total = Double(words.count) + overlap
        let amount = (current * total - Double(index)) / overlap
        return min(max(amount, 0), 1)
    }

    private var estimatedHeight: CGFloat {
        CGFloat((words.count / 7) + 1) * 38
    }
}

/// Minimal wrapping layout so each word can be tinted individually.
/// Copy it alongside the component — the effect depends on it.
struct RevealFlow: Layout {
    var spacing: CGFloat = 8
    var lineSpacing: CGFloat = 8

    func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
        let maxWidth = proposal.width ?? .infinity
        var x: CGFloat = 0, y: CGFloat = 0, lineHeight: CGFloat = 0, widest: CGFloat = 0

        for subview in subviews {
            let size = subview.sizeThatFits(.unspecified)
            if x > 0 && x + size.width > maxWidth {
                y += lineHeight + lineSpacing
                x = 0
                lineHeight = 0
            }
            x += size.width + spacing
            widest = max(widest, x - spacing)
            lineHeight = max(lineHeight, size.height)
        }
        return CGSize(width: min(widest, maxWidth), height: y + lineHeight)
    }

    func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
        var x = bounds.minX, y = bounds.minY, lineHeight: CGFloat = 0

        for subview in subviews {
            let size = subview.sizeThatFits(.unspecified)
            if x > bounds.minX && x + size.width > bounds.maxX {
                y += lineHeight + lineSpacing
                x = bounds.minX
                lineHeight = 0
            }
            subview.place(at: CGPoint(x: x, y: y), proposal: ProposedViewSize(size))
            x += size.width + spacing
            lineHeight = max(lineHeight, size.height)
        }
    }
}

private extension Color {
    /// Blend between two colours. `Color.mix` is iOS 18, so this keeps the
    /// component working on iOS 17 by stepping instead of interpolating.
    func blended(with other: Color, by amount: Double) -> Color {
        if #available(iOS 18.0, macOS 15.0, *) {
            return self.mix(with: other, by: amount, in: .perceptual)
        }
        return amount > 0.5 ? other : self
    }
}

#Preview {
    ScrollRevealText(progress: 0.55)
        .frame(width: 340)
        .padding(30)
}
iOS 17 · No dependencies

SwiftUI note. Reads its own position through the .scrollView coordinate space (iOS 17), so it needs no ScrollViewReader and no offset plumbing at the call site. Pass `progress` to drive it from something other than scroll.

Dependencies

SwiftUI
No external dependencies

Requires iOS 17

Flutter
No external dependencies

Requires Flutter 3.16