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


