Skip to content
Char Wave preview
An animated render of the SwiftUI source on this page.

Char Wave

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

A sine wave travelling through the letterforms, forever and gently.

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

  • text
  • wave
  • sine
  • loop
  • playful
  • characters
  • animation

The actual source

CharWave.swift
// Char Wave · syxUI · https://syxui.dev/components/char-wave
// Free in any project. Keep this line and credit syxUI where a person can read it.

import SwiftUI

/// A sine wave travelling through the letterforms, forever and gently.
///
/// Character `i` rides `amplitude * sin(2π * (phase - i * lagFraction))`,
/// where `lagFraction = charLag / period` puts each glyph a fixed fraction of
/// the period behind its neighbour — the shape that reads as a rope rather
/// than independent jitter.
struct CharWave: View {
    var text: String = "wavelength"
    /// Vertical travel at the crest, in points.
    var amplitude: CGFloat = 8
    /// One full wave cycle, in milliseconds.
    var period: Double = 1600
    /// Delay between one character's crest and the next's, in milliseconds.
    var charLag: Double = 55
    /// Swing glyphs ±5° in phase with the offset, hinged at the baseline.
    var rotate: Bool = false
    var color: Color = Color(white: 0.07)
    /// Preview override. `0...1` is exactly one period; the loop is seamless
    /// at the wrap since a sine repeats every whole cycle regardless of lag.
    var phase: Double? = nil

    @Environment(\.accessibilityReduceMotion) private var reduceMotion

    private let fontSize: CGFloat = 40

    var body: some View {
        // A sine is not an animatable property's endpoint — it is a
        // non-linear function of the clock, so a driven `@State` value only
        // ever shows the resting phase. `TimelineView` re-evaluates every
        // frame instead, which is what a continuous wave needs.
        TimelineView(.animation(paused: phase != nil || reduceMotion)) { context in
            let current = currentPhase(at: context.date)
            line(at: current)
        }
        .frame(height: fontSize * 1.3 + amplitude * 2 + 12)
        .accessibilityElement(children: .ignore)
        .accessibilityLabel(text)
    }

    private func currentPhase(at date: Date) -> Double {
        if let phase { return phase }
        if reduceMotion { return 0 }
        let ms = date.timeIntervalSinceReferenceDate * 1000
        return (ms / period).truncatingRemainder(dividingBy: 1)
    }

    private func line(at current: Double) -> some View {
        let lagFraction = period > 0 ? charLag / period : 0
        let characters = Array(text.enumerated())

        return HStack(spacing: 0) {
            ForEach(characters, id: \.offset) { index, character in
                self.character(character, index: index, current: current, lagFraction: lagFraction)
            }
        }
    }

    @ViewBuilder
    private func character(
        _ character: Character,
        index: Int,
        current: Double,
        lagFraction: Double
    ) -> some View {
        let glyph = Text(String(character))
            .font(.system(size: fontSize, weight: .semibold))
            .foregroundStyle(color)

        if reduceMotion {
            // A settled flat baseline, not a frozen mid-wave frame — this
            // loop has no natural "finished" pose, so rest is the flat line.
            glyph
        } else {
            let angle = 2 * Double.pi * (current - Double(index) * lagFraction)
            let wave = sin(angle)

            glyph
                .scaleEffect(1 + 0.03 * wave, anchor: .bottom)
                .rotationEffect(.degrees(rotate ? 5 * wave : 0), anchor: .bottom)
                .offset(y: -amplitude * wave)
        }
    }
}

#Preview {
    CharWave()
        .padding(30)
}
iOS 17 · No dependencies

SwiftUI note. One Text view per character discards kerning and complex-script shaping — fine for this short, playful use, not for body copy. TimelineView redraws every frame because the wave is a sine of the clock, not an animatable endpoint; keep the string short on a screen with many instances.

Dependencies

SwiftUI
No external dependencies

Requires iOS 17

Flutter
No external dependencies

Requires Flutter 3.27