
Marquee Text
From syxUI — written for both platforms, not translated between them.
A seamless ticker that scrolls a phrase forever, with soft edge fades.
Text Effects · SwiftUI · Flutter · iOS 17 · Flutter 3.27
- text
- marquee
- ticker
- scroll
- loop
- banner
- animation
The actual source
MarqueeText.swift
// Marquee Text · syxUI · https://syxui.dev/components/marquee-text
// Free in any project. Keep this line and credit syxUI where a person can read it.
import SwiftUI
/// A seamless ticker that scrolls a phrase forever, with soft edge fades.
///
/// The content is laid out twice end to end; the pair translates by
/// `-contentWidth * phase`, and because copy two sits exactly where copy one
/// started, the wrap at `phase == 1` is invisible.
struct MarqueeText: View {
var text: String = "Copy · paste · ship"
var speed: Double = 45
var gap: CGFloat = 48
var separator: String = "•"
var fadeEdges: Bool = true
var reverse: 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
@State private var animated: Double = 0
@State private var contentWidth: CGFloat = 0
@State private var started = false
private var current: Double { phase ?? animated }
var body: some View {
GeometryReader { proxy in
let offset = (reverse ? 1 : -1) * contentWidth * current
HStack(spacing: gap) {
copy
copy
}
.fixedSize()
.offset(x: offset)
.frame(width: proxy.size.width, height: proxy.size.height, alignment: .leading)
}
.clipped()
.mask {
if fadeEdges {
LinearGradient(
stops: [
.init(color: .clear, location: 0),
.init(color: .black, location: 0.08),
.init(color: .black, location: 0.92),
.init(color: .clear, location: 1),
],
startPoint: .leading,
endPoint: .trailing
)
} else {
Rectangle()
}
}
.onAppear(perform: start)
.onChange(of: contentWidth) { _, _ in start() }
.accessibilityElement(children: .ignore)
.accessibilityLabel(text)
}
/// One phrase-plus-separator group, measured once so the loop offset is exact.
private var copy: some View {
HStack(spacing: gap) {
Text(text)
Text(separator)
}
.lineLimit(1)
.background {
GeometryReader { g in
Color.clear.onAppear { contentWidth = g.size.width + gap }
}
}
}
private func start() {
guard phase == nil, !reduceMotion, contentWidth > 0, !started else { return }
started = true
let period = contentWidth / max(speed, 1)
withAnimation(.linear(duration: period).repeatForever(autoreverses: false)) {
animated = 1
}
}
}
#Preview {
MarqueeText()
.frame(width: 380, height: 60)
}
iOS 17 · No dependencies
SwiftUI note. Width is measured once from a hidden GeometryReader background, so wrap the view in `.id(text)` if you swap phrases at runtime — otherwise the loop keeps the old width until the view is recreated.
Dependencies
- SwiftUI
- No external dependencies
- Flutter
- No external dependencies
Requires iOS 17
Requires Flutter 3.27


