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


