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

Focus Text

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

Words sit out of focus until a bracketed window drifts across and sharpens one.

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

  • text
  • focus
  • blur
  • brackets
  • attention
  • editorial
  • animation

The actual source

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

import SwiftUI

/// Words sit out of focus until a bracketed window drifts across and
/// sharpens one.
///
/// Distinct from a one-shot reveal: this never settles. It is a roving
/// attention device, cycling through every word for as long as it is on
/// screen, and the brackets are the reason to reach for it over a plain
/// blur crossfade.
struct FocusText: View {
    var text: String = "copy paste ship"
    var blurRadius: Double = 5.5
    /// How long the focused word holds before the next one takes over, in
    /// milliseconds.
    var hold: Double = 1200
    /// How long the brackets take to travel to the next word, in
    /// milliseconds.
    var travel: Double = 420
    var brackets: Bool = true
    var accent: Color = Color(red: 0.133, green: 0.827, blue: 0.651)
    /// Preview override. `0...1` sweeps every word once; the wrap back to
    /// the first word is not a special case, so the loop is seamless.
    var phase: Double? = nil

    @Environment(\.accessibilityReduceMotion) private var reduceMotion
    @State private var frames: [Int: CGRect] = [:]

    private let fontSize: CGFloat = 30
    private let textColor = Color(white: 0.07)
    /// Fixed handover durations, in milliseconds — asymmetric on purpose:
    /// the incoming word sharpens faster than the outgoing one re-blurs, so
    /// there is a brief window where both read clearly and the eye is led
    /// rather than yanked.
    private let sharpenDuration: Double = 260
    private let reblurDuration: Double = 320

    var body: some View {
        let words = self.words

        TimelineView(.animation(paused: reduceMotion)) { context in
            let current = currentPhase(at: context.date)
            let timeline = Timeline(words: words, hold: hold, current: current)

            ZStack(alignment: .topLeading) {
                FocusFlowLayout(spacing: fontSize * 0.32, lineSpacing: fontSize * 0.4) {
                    ForEach(words.indices, id: \.self) { index in
                        word(words[index], index: index, timeline: timeline)
                            .background(
                                GeometryReader { geometry in
                                    Color.clear.preference(
                                        key: WordFramePreferenceKey.self,
                                        value: [index: geometry.frame(in: .named("focusTextLine"))]
                                    )
                                }
                            )
                    }
                }
                .coordinateSpace(.named("focusTextLine"))
                .onPreferenceChange(WordFramePreferenceKey.self) { frames = $0 }

                if brackets, let rect = bracketRect(timeline: timeline) {
                    CornerBrackets(color: accent)
                        .frame(width: rect.width + 8, height: rect.height + 8)
                        .offset(x: rect.minX - 4, y: rect.minY - 4)
                        .allowsHitTesting(false)
                }
            }
        }
        .accessibilityElement(children: .ignore)
        .accessibilityLabel(text)
    }

    private var words: [String] {
        text.split(whereSeparator: \.isWhitespace).map(String.init)
    }

    private func currentPhase(at date: Date) -> Double {
        if let phase { return min(max(phase, 0), 1) }
        if reduceMotion { return 0 }
        let totalMs = hold * Double(max(words.count, 1))
        guard totalMs > 0 else { return 0 }
        let ms = date.timeIntervalSinceReferenceDate * 1000
        return (ms / totalMs).truncatingRemainder(dividingBy: 1)
    }

    @ViewBuilder
    private func word(_ text: String, index: Int, timeline: Timeline) -> some View {
        let state = timeline.state(for: index, blurRadius: blurRadius, reduceMotion: reduceMotion)
        Text(text)
            .font(.system(size: fontSize, weight: .semibold))
            .foregroundStyle(textColor)
            .blur(radius: state.blur)
            .opacity(state.opacity)
    }

    private func bracketRect(timeline: Timeline) -> CGRect? {
        guard let active = frames[timeline.activeIndex] else { return nil }
        guard let previous = frames[timeline.previousIndex], timeline.localMs < travel else {
            return active
        }
        let eased = spring(min(timeline.localMs / max(travel, 1), 1))
        return CGRect(
            x: previous.minX + (active.minX - previous.minX) * eased,
            y: previous.minY + (active.minY - previous.minY) * eased,
            width: previous.width + (active.width - previous.width) * eased,
            height: previous.height + (active.height - previous.height) * eased
        )
    }

    /// Closed-form damped spring standing in for `.spring(response: 0.45,
    /// dampingFraction: 0.80)`, evaluated per frame so the brackets can be
    /// scrubbed to an exact `phase` rather than only played forward once.
    private func spring(_ u: Double) -> Double {
        guard u > 0 else { return 0 }
        guard u < 1 else { return 1 }
        let zeta = 0.80
        let omega = 8.0
        let damped = omega * (1 - zeta * zeta).squareRoot()
        return 1 - exp(-zeta * omega * u)
            * (cos(damped * u) + zeta * omega / damped * sin(damped * u))
    }

    /// Snapshot of where the loop is right now: which word is settling in,
    /// which one (if any) is still mid-re-blur, and how far into its own
    /// `hold` slot the loop is.
    private struct Timeline {
        let activeIndex: Int
        let previousIndex: Int
        let localMs: Double
        let sharpenDuration: Double
        let reblurDuration: Double

        init(words: [String], hold: Double, current: Double) {
            let count = max(words.count, 1)
            let totalMs = max(hold * Double(count), 1)
            let elapsedMs = current * totalMs
            activeIndex = min(Int(elapsedMs / hold), count - 1)
            previousIndex = (activeIndex - 1 + count) % count
            localMs = elapsedMs.truncatingRemainder(dividingBy: hold)
            sharpenDuration = 260
            reblurDuration = 320
        }

        func state(for index: Int, blurRadius: Double, reduceMotion: Bool) -> (blur: Double, opacity: Double) {
            if reduceMotion {
                return index == 0 ? (0, 1) : (blurRadius, 0.55)
            }
            if index == activeIndex {
                let eased = easeOut(min(localMs / sharpenDuration, 1))
                return (blurRadius * (1 - eased), 0.55 + 0.45 * eased)
            }
            if index == previousIndex, localMs < reblurDuration {
                let eased = easeIn(min(localMs / reblurDuration, 1))
                return (blurRadius * eased, 1 - 0.45 * eased)
            }
            return (blurRadius, 0.55)
        }

        private func easeOut(_ t: Double) -> Double { 1 - pow(1 - t, 3) }
        private func easeIn(_ t: Double) -> Double { t * t * t }
    }
}

private struct WordFramePreferenceKey: PreferenceKey {
    // `let`, not `var`: under Swift 6 a mutable static is shared mutable
    // state and will not compile. A PreferenceKey's default never changes.
    static let defaultValue: [Int: CGRect] = [:]
    static func reduce(value: inout [Int: CGRect], nextValue: () -> [Int: CGRect]) {
        value.merge(nextValue()) { _, new in new }
    }
}

/// Four L-shaped corner marks framing a rect, drawn in one `Canvas` pass.
private struct CornerBrackets: View {
    var armLength: CGFloat = 10
    var lineWidth: CGFloat = 1.5
    var color: Color = .accentColor

    var body: some View {
        Canvas { context, size in
            var path = Path()
            let arm = min(armLength, min(size.width, size.height) / 2)

            path.move(to: CGPoint(x: 0, y: arm))
            path.addLine(to: CGPoint(x: 0, y: 0))
            path.addLine(to: CGPoint(x: arm, y: 0))

            path.move(to: CGPoint(x: size.width - arm, y: 0))
            path.addLine(to: CGPoint(x: size.width, y: 0))
            path.addLine(to: CGPoint(x: size.width, y: arm))

            path.move(to: CGPoint(x: size.width, y: size.height - arm))
            path.addLine(to: CGPoint(x: size.width, y: size.height))
            path.addLine(to: CGPoint(x: size.width - arm, y: size.height))

            path.move(to: CGPoint(x: arm, y: size.height))
            path.addLine(to: CGPoint(x: 0, y: size.height))
            path.addLine(to: CGPoint(x: 0, y: size.height - arm))

            context.stroke(path, with: .color(color), lineWidth: lineWidth)
        }
        .accessibilityHidden(true)
    }
}

/// Minimal wrapping layout so words can each carry their own blur/opacity.
/// Copy it along with FocusText — it is the only piece the effect depends on.
private struct FocusFlowLayout: Layout {
    var spacing: CGFloat = 8
    var lineSpacing: CGFloat = 10

    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)
        }
    }
}

#Preview {
    FocusText()
        .frame(width: 320, alignment: .leading)
        .padding(30)
}
iOS 17 · No dependencies

SwiftUI note. One Text view per word, so kerning and shaping survive within each word but not across the gaps between them — a fine trade at this scale. Word frames are measured with a GeometryReader-preference pass, so the brackets take one layout cycle to appear on first mount.

Dependencies

SwiftUI
No external dependencies

Requires iOS 17

Flutter
No external dependencies

Requires Flutter 3.27