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

Colour Cycle Text

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

A ribbon of colour crawling glyph by glyph through a word.

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

  • text
  • colour
  • hue
  • rainbow
  • cycle
  • playful
  • animation

The actual source

ColourCycleText.swift
// Colour Cycle Text · syxUI · https://syxui.dev/components/colour-cycle-text
// Free in any project. Keep this line and credit syxUI where a person can read it.

import SwiftUI

/// A ribbon of colour crawling glyph by glyph through a word.
///
/// Character `i` takes its fill from the palette at
/// `(phase + i * lagFraction) mod 1`, so the ribbon visibly travels rather
/// than sitting fixed to a position the way `gradient-text`'s mask does.
/// Hue wraps the long way round under naive interpolation, so the driver is
/// `TimelineView(.animation)` rather than `withAnimation`.
struct ColourCycleText: View {
    var text: String = "technicolour"
    var colors: [Color] = [Color(red: 0.357, green: 0.549, blue: 1.000), Color(red: 0.698, green: 0.357, blue: 1.000), Color(red: 1.000, green: 0.357, blue: 0.659), Color(red: 1.000, green: 0.714, blue: 0.357)]
    var period: Double = 4.2
    /// Per-character lag, in milliseconds — roughly 0.045 of a cycle at the
    /// default period, which puts about four characters between palette stops.
    var lag: Double = 190
    var bounce: Bool = true
    /// Ignores the palette and walks the full hue wheel instead.
    var fullHue: Bool = false
    /// Preview override. When set, the component renders that point of one loop
    /// instead of driving its own clock.
    var phase: Double? = nil

    @Environment(\.accessibilityReduceMotion) private var reduceMotion
    @Environment(\.self) private var env

    var body: some View {
        Group {
            if reduceMotion && phase == nil {
                row(cycle: 0)
            } else {
                TimelineView(.animation) { context in
                    let elapsed = phase ?? (context.date.timeIntervalSinceReferenceDate
                        .truncatingRemainder(dividingBy: period) / period)
                    row(cycle: elapsed)
                }
            }
        }
        .accessibilityElement(children: .ignore)
        .accessibilityLabel(text)
    }

    private func row(cycle: Double) -> some View {
        HStack(spacing: 0) {
            ForEach(Array(text.enumerated()), id: \.offset) { i, ch in
                let lagFraction = (lag / 1000) / period
                let t = (cycle + Double(i) * lagFraction).truncatingRemainder(dividingBy: 1)
                let normalised = t < 0 ? t + 1 : t
                Text(String(ch))
                    .foregroundStyle(colour(at: normalised))
                    .offset(y: bounce ? sin(normalised * 2 * .pi) * 1.5 : 0)
            }
        }
    }

    private func colour(at t: Double) -> Color {
        if fullHue {
            return Color(hue: t, saturation: 0.72, brightness: 0.98)
        }
        guard !colors.isEmpty else { return .primary }
        guard colors.count > 1 else { return colors[0] }

        let scaled = t * Double(colors.count)
        let index = Int(scaled) % colors.count
        let nextIndex = (index + 1) % colors.count
        let localT = scaled - scaled.rounded(.down)
        return lerp(colors[index], colors[nextIndex], localT)
    }

    /// `Color` interpolation via `Color.Resolved` — first-party and
    /// cross-platform on this repo's floor, unlike `UIColor.getRed`.
    private func lerp(_ a: Color, _ b: Color, _ t: Double) -> Color {
        let x = a.resolve(in: env)
        let y = b.resolve(in: env)
        return Color(
            red: Double(x.red) + (Double(y.red) - Double(x.red)) * t,
            green: Double(x.green) + (Double(y.green) - Double(x.green)) * t,
            blue: Double(x.blue) + (Double(y.blue) - Double(x.blue)) * t
        )
    }
}

#Preview {
    ColourCycleText()
        .font(.system(size: 34, weight: .bold))
        .frame(width: 320, height: 120)
}
iOS 17 · No dependencies

SwiftUI note. Uses `Color.resolve(in:)` to interpolate palette stops rather than `UIColor.getRed`, which is UIKit-only and would break the macOS preview build. Driven by `TimelineView(.animation)` because hue interpolation is not a property `withAnimation` can safely wrap.

Dependencies

SwiftUI
No external dependencies

Requires iOS 17

Flutter
No external dependencies

Requires Flutter 3.27