
Elastic Underline
From syxUI — written for both platforms, not translated between them.
An underline that stretches ahead of itself, then snaps under the next word.
Text Effects · SwiftUI · Flutter · iOS 17 · Flutter 3.27
- text
- underline
- elastic
- spring
- emphasis
- indicator
- animation
The actual source
ElasticUnderline.swift
// Elastic Underline · syxUI · https://syxui.dev/components/elastic-underline
// Free in any project. Keep this line and credit syxUI where a person can read it.
import SwiftUI
private struct WordFrameKey: 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 }
}
}
/// An underline that stretches ahead of itself, then snaps under the next word.
///
/// Two springs, deliberately mismatched: the bar's `x` settles a touch faster
/// and cleaner than its `width`, so on every hop the width visibly lags,
/// overshoots, and snaps back — the "elastic" read. Because `phase` has to
/// scrub this deterministically for the preview renderer, both springs are
/// closed-form step-response functions of elapsed time rather than live
/// `.spring()` animations — same shape, evaluated instead of simulated.
struct ElasticUnderline: View {
var text: String = "native · fast · yours"
var accent: Color = Color(red: 0.059, green: 0.384, blue: 0.996)
var thickness: CGFloat = 2.5
/// Time to travel between words, in ms.
var travel: Double = 480
/// Extra width at mid-flight, as a fraction of the target width.
var stretch: Double = 0.55
var squash: Bool = true
/// Preview override. When set, samples that point of one loop over every word.
var phase: Double? = nil
@Environment(\.accessibilityReduceMotion) private var reduceMotion
@State private var animated: Double = 0
@State private var frames: [Int: CGRect] = [:]
/// Rest time on each word, in ms — fixed pacing, not a control worth
/// exposing on top of `travel`.
private let hold: Double = 1400
private var words: [String] { text.split(separator: " ").map(String.init) }
private var current: Double { phase ?? animated }
private var cycleSeconds: Double {
Double(words.count) * (hold + travel) / 1000
}
var body: some View {
ZStack(alignment: .topLeading) {
HStack(spacing: 10) {
ForEach(Array(words.enumerated()), id: \.offset) { index, word in
Text(word)
.font(.system(size: 24, weight: .semibold))
.background {
GeometryReader { g in
Color.clear.preference(
key: WordFrameKey.self,
value: [index: g.frame(in: .named("elastic-underline"))]
)
}
}
}
}
.accessibilityElement(children: .ignore)
.accessibilityLabel(text)
if let bar = barGeometry {
Capsule()
.fill(accent)
.frame(width: bar.width, height: thickness * bar.squash)
.position(x: bar.x, y: bar.y)
.accessibilityHidden(true)
}
}
.coordinateSpace(name: "elastic-underline")
.onPreferenceChange(WordFrameKey.self) { frames = $0 }
.onAppear(perform: start)
}
private struct BarGeometry {
var x: CGFloat
var y: CGFloat
var width: CGFloat
var squash: CGFloat
}
/// Where the bar sits right now, derived entirely from `current` and the
/// measured word frames — nil only until the first layout pass reports
/// frames for every word.
private var barGeometry: BarGeometry? {
guard !words.isEmpty, frames.count == words.count else { return nil }
let cycle = max(cycleSeconds, 0.0001)
let t = reduceMotion ? 0 : current
let elapsed = t * cycle
let segmentDuration = (hold + travel) / 1000
let segmentIndex = min(Int(elapsed / max(segmentDuration, 0.0001)), words.count - 1)
let localT = elapsed - Double(segmentIndex) * segmentDuration
let holdSeconds = hold / 1000
let travelSeconds = travel / 1000
guard let fromRect = frames[segmentIndex] else { return nil }
let restY = fromRect.maxY + 4
if reduceMotion || localT < holdSeconds {
return BarGeometry(x: fromRect.midX, y: restY, width: fromRect.width, squash: 1)
}
let toIndex = (segmentIndex + 1) % words.count
guard let toRect = frames[toIndex] else { return nil }
let travelT = localT - holdSeconds
let travelProgress = min(travelT / max(travelSeconds, 0.0001), 1)
let bump = sin(.pi * travelProgress)
let xEase = Self.springStep(travelT, response: 0.40, damping: 0.78)
let wEase = Self.springStep(travelT, response: 0.52, damping: 0.62)
let x = fromRect.midX + (toRect.midX - fromRect.midX) * xEase
let y = restY + (toRect.maxY + 4 - restY) * xEase
let baseWidth = fromRect.width + (toRect.width - fromRect.width) * wEase
let width = baseWidth * (1 + stretch * bump)
let squashScale = squash ? (1 - 0.4 * bump) : 1
return BarGeometry(x: x, y: y, width: width, squash: squashScale)
}
private func start() {
guard phase == nil, !reduceMotion else { return }
withAnimation(.linear(duration: cycleSeconds).repeatForever(autoreverses: false)) {
animated = 1
}
}
/// Closed-form under-damped step response, 0 → 1. Not a live `.spring()`
/// call — it has to be a pure function of elapsed time so `phase` can
/// sample it deterministically for the preview.
private static func springStep(_ t: Double, response: Double, damping: Double) -> Double {
guard t > 0 else { return 0 }
let omega = 2 * Double.pi / response
let zeta = damping
let omegaD = omega * sqrt(max(1 - zeta * zeta, 0.0001))
let envelope = exp(-zeta * omega * t)
return 1 - envelope * (cos(omegaD * t) + (zeta * omega / omegaD) * sin(omegaD * t))
}
}
#Preview {
ElasticUnderline()
.frame(width: 380, height: 100)
.padding(24)
}
iOS 17 · No dependencies
SwiftUI note. Word frames are measured with a `PreferenceKey`, not `matchedGeometryEffect` — the latter gives one spring for the whole frame, which loses the two-stiffness mismatch that makes this component read as elastic.
Dependencies
- SwiftUI
- No external dependencies
- Flutter
- No external dependencies
Requires iOS 17
Requires Flutter 3.27


