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

Rotating Text

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

A fixed lead-in with one word rotating vertically in a clipped window.

Text Effects · SwiftUI · Flutter · iOS 17 · Flutter 3.16

  • text
  • rotating
  • carousel
  • headline
  • words
  • animation

The actual source

RotatingText.swift
// Rotating Text · syxUI · https://syxui.dev/components/rotating-text
// Free in any project. Keep this line and credit syxUI where a person can read it.

import SwiftUI

/// A fixed lead-in with one word rotating vertically beneath a clipped window.
///
/// The window is sized to the tallest word, so the line never reflows as words
/// change — the single detail that separates this from a jittery mess.
struct RotatingText: View {
    var prefix: String = "Built for"
    var words: [String] = ["iOS", "Flutter", "both"]
    var font: Font = .system(size: 32, weight: .bold)
    var accent: Color = Color(red: 0.15, green: 0.45, blue: 1.0)
    /// Seconds each word is on screen.
    var interval: Double = 1.8
    var phase: Double? = nil

    @Environment(\.accessibilityReduceMotion) private var reduceMotion
    @State private var animated: Double = 0

    private var current: Double { phase ?? animated }
    private var lineHeight: CGFloat { 42 }

    var body: some View {
        HStack(alignment: .center, spacing: 10) {
            Text(prefix)
                .font(font)

            window
        }
        .onAppear(perform: start)
        .accessibilityElement()
        .accessibilityLabel("\(prefix) \(words.joined(separator: ", "))")
    }

    private var window: some View {
        // The whole strip is offset by one continuous value; clipping to a
        // single line turns that into a rotation.
        let total = current * Double(words.count)
        let offset = CGFloat(total) * lineHeight

        return VStack(spacing: 0) {
            // The first word is repeated at the end so the wrap is seamless.
            ForEach(Array((words + [words.first ?? ""]).enumerated()), id: \.offset) { _, word in
                Text(word)
                    .font(font)
                    .foregroundStyle(accent)
                    .frame(height: lineHeight)
                    .frame(maxWidth: .infinity, alignment: .leading)
            }
        }
        .offset(y: -offset)
        .frame(width: widestWord, height: lineHeight, alignment: .topLeading)
        .clipped()
    }

    /// Reserve the width of the longest word so the lead-in never shifts.
    private var widestWord: CGFloat {
        let longest = words.map(\.count).max() ?? 4
        return CGFloat(longest) * 19
    }

    private func start() {
        guard phase == nil, !reduceMotion else { return }
        withAnimation(
            .linear(duration: interval * Double(words.count)).repeatForever(autoreverses: false)
        ) {
            animated = 1
        }
    }
}

#Preview {
    RotatingText()
        .padding(30)
}
iOS 17 · No dependencies

SwiftUI note. The window is a fixed width based on the longest word, so the lead-in never shifts. Widen the multiplier if you use a wider face than the system font.

Dependencies

SwiftUI
No external dependencies

Requires iOS 17

Flutter
No external dependencies

Requires Flutter 3.16