Skip to content

Marquee Text

A single line of text that scrolls horizontally only when it does not fit.

Marquee Text, playing
  • Text
  • iOS 18.0+
  • intermediate
Download .swift
Origin
Original work
Found by
marquee · scrolling text · ticker · overflow · long text · now playing · auto scroll · marquesina · texto desplazable · desplazamiento · texto largo · reproduciendo · desbordamiento

A single line that scrolls horizontally, only when it does not fit.

With Reduce Motion on, a long line is truncated instead of scrolling.

struct MarqueeTextDemo: View {
var body: some View {
ZStack {
LinearGradient(colors: [.purple, .indigo, .black], startPoint: .top, endPoint: .bottom)
.ignoresSafeArea()
VStack(spacing: 24) {
RoundedRectangle(cornerRadius: 32)
.fill(.linearGradient(colors: [.pink, .orange], startPoint: .topLeading, endPoint: .bottomTrailing))
.aspectRatio(1, contentMode: .fit)
.overlay {
Image(systemName: "music.note")
.resizable()
.scaledToFit()
.fontWeight(.semibold)
.padding(80)
.foregroundStyle(.white)
}
.accessibilityHidden(true)
VStack(alignment: .leading, spacing: 6) {
MarqueeText(text: "The Very Long Song Title That Could Never Fit on One Line")
.font(.title2.bold())
.foregroundStyle(.white)
MarqueeText(text: "Short and still")
.font(.title3)
.foregroundStyle(.white.opacity(0.7))
}
}
.padding(32)
}
}
}
ParameterTypeDefaultDescription
textStringRequired
speedDouble42Points per second.
pauseDouble1Seconds to wait at the start of each lap.

Copy this code into your project, or download the file.

MarqueeText.swift
// SPDX-License-Identifier: MIT
// Copyright (c) 2026 Oscar Canton and SwiftUI Blocks contributors
import SwiftUI
/// A single line that scrolls horizontally, only when it does not fit.
///
/// With Reduce Motion on, a long line is truncated instead of scrolling.
struct MarqueeText: View {
let text: String
/// Points per second.
var speed: Double = 42
/// Seconds to wait at the start of each lap.
var pause: Double = 1
@ScaledMetric(relativeTo: .body) private var gap = 48.0
@State private var textWidth = 0.0
@State private var containerWidth = 0.0
@State private var start = Date.now
@Environment(\.accessibilityReduceMotion) private var reduceMotion
private var scrolls: Bool {
containerWidth > 0 && textWidth > containerWidth && speed > 0 && !reduceMotion
}
var body: some View {
Text(text)
.lineLimit(1)
.opacity(scrolls ? 0 : 1)
.frame(maxWidth: .infinity, alignment: .leading)
.onGeometryChange(for: Double.self) { $0.size.width } action: { containerWidth = $0 }
.background {
Text(text)
.lineLimit(1)
.fixedSize()
.hidden()
.onGeometryChange(for: Double.self) { $0.size.width } action: { textWidth = $0 }
}
.overlay(alignment: .leading) {
if scrolls { scrollingCopies }
}
.mask {
if scrolls {
LinearGradient(
stops: [
.init(color: .clear, location: 0),
.init(color: .black, location: 0.03),
.init(color: .black, location: 0.92),
.init(color: .clear, location: 1),
],
startPoint: .leading,
endPoint: .trailing
)
} else {
Color.black
}
}
.accessibilityElement(children: .ignore)
.accessibilityLabel(text)
.task(id: text) { start = .now }
}
/// The second copy follows the first, so the end of a lap looks just like its start.
private var scrollingCopies: some View {
TimelineView(.animation(minimumInterval: 1.0 / 60.0)) { context in
HStack(spacing: gap) {
Text(text)
Text(text)
}
.lineLimit(1)
.fixedSize()
.accessibilityRepresentation { EmptyView() }
.offset(x: -offset(at: context.date))
}
}
private func offset(at date: Date) -> Double {
let lapDistance = textWidth + gap
let lapDuration = pause + lapDistance / speed
let timeInLap = date.timeIntervalSince(start).truncatingRemainder(dividingBy: lapDuration)
return max(timeInLap - pause, 0) * speed
}
}
// MARK: - Demo
struct MarqueeTextDemo: View {
var body: some View {
ZStack {
LinearGradient(colors: [.purple, .indigo, .black], startPoint: .top, endPoint: .bottom)
.ignoresSafeArea()
VStack(spacing: 24) {
RoundedRectangle(cornerRadius: 32)
.fill(.linearGradient(colors: [.pink, .orange], startPoint: .topLeading, endPoint: .bottomTrailing))
.aspectRatio(1, contentMode: .fit)
.overlay {
Image(systemName: "music.note")
.resizable()
.scaledToFit()
.fontWeight(.semibold)
.padding(80)
.foregroundStyle(.white)
}
.accessibilityHidden(true)
VStack(alignment: .leading, spacing: 6) {
MarqueeText(text: "The Very Long Song Title That Could Never Fit on One Line")
.font(.title2.bold())
.foregroundStyle(.white)
MarqueeText(text: "Short and still")
.font(.title3)
.foregroundStyle(.white.opacity(0.7))
}
}
.padding(32)
}
}
}
#Preview {
MarqueeTextDemo()
}