Skip to content
Elastic Underline preview
An animated render of the SwiftUI source on this page.

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

Requires iOS 17

Flutter
No external dependencies

Requires Flutter 3.27