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

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

Requires iOS 17

Flutter
No external dependencies

Requires Flutter 3.27