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

Circular Text

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

Type set around a circle, turning slowly, each glyph on its tangent.

Text Effects · SwiftUI · Flutter · iOS 17 · Flutter 3.27

  • text
  • circular
  • arc
  • path
  • badge
  • rotate
  • animation

The actual source

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

import SwiftUI

/// Type set around a circle, turning slowly, each glyph on its tangent.
///
/// Character `i` is offset outward then rotated by its own angle — offset
/// first, rotation second, so it is carried around the circle and stays
/// tangent to it. The whole ring then turns as one on top.
struct CircularText: View {
    var text: String = "COPY · PASTE · SHIP · "
    var radius: CGFloat = 96
    /// Degrees of the circle the text spans. 360 wraps all the way around.
    var arc: Double = 360
    /// Seconds per full turn. 0 holds the ring still.
    var period: Double = 20
    var startAngle: Double = -90
    var baseline: CircularTextBaseline = .outside
    /// Preview override. When set, the component renders that point of one
    /// turn instead of driving its own clock.
    var phase: Double? = nil

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

    private var current: Double { phase ?? animated }
    private var step: Double {
        let count = max(text.count, 1)
        return arc / Double(count)
    }

    var body: some View {
        ZStack {
            ForEach(Array(text.enumerated()), id: \.offset) { i, ch in
                // `inside` mirrors the walking order and flips each glyph
                // 180° so the baseline faces the centre and the text still
                // reads left to right from within the circle.
                let direction: Double = baseline == .inside ? -1 : 1
                let angle = startAngle + direction * Double(i) * step
                Text(String(ch))
                    .offset(y: -radius)
                    .rotationEffect(.degrees(baseline == .inside ? angle + 180 : angle))
            }
        }
        .rotationEffect(.degrees(period > 0 ? current * 360 : 0))
        .frame(width: radius * 2 + 40, height: radius * 2 + 40)
        .onAppear(perform: start)
        .accessibilityElement(children: .ignore)
        .accessibilityLabel(text)
    }

    private func start() {
        guard phase == nil, !reduceMotion, period > 0 else { return }
        withAnimation(.linear(duration: period).repeatForever(autoreverses: false)) {
            animated = 1
        }
    }
}

enum CircularTextBaseline: String { case outside, inside }

#Preview {
    CircularText()
        .frame(width: 260, height: 260)
}
iOS 17 · No dependencies

SwiftUI note. Spacing is uniform angular division, correct for uppercase with generous tracking. For proportional spacing measure real glyph widths with `context.resolve(Text(...)).measure(in:)` inside a `Canvas` and convert arc length to angle — worth the upgrade for long lowercase strings.

Dependencies

SwiftUI
No external dependencies

Requires iOS 17

Flutter
No external dependencies

Requires Flutter 3.27