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

Flip Text

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

Characters swing in on a hinge, one after another, with real perspective.

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

  • text
  • flip
  • 3d
  • perspective
  • stagger
  • reveal
  • animation

The actual source

FlipText3D.swift
// Flip Text · syxUI · https://syxui.dev/components/flip-text-3d
// Free in any project. Keep this line and credit syxUI where a person can read it.

import SwiftUI

/// Characters swing in on a hinge, one after another, with real perspective.
///
/// Each unit rotates from just past edge-on to a few degrees past flat, which
/// is what gives the hinge its weight — a flat start/end would read as a
/// simple fade. `progress` drives one continuous timeline; every unit reads
/// its own window of it, so this is a single lerped view, not an `if/else`.
struct FlipText3D: View {
    var text: String = "Flip it over"
    var unit: FlipUnit = .character
    var stagger: Double = 42
    var duration: Double = 480
    var perspective: Double = 0.55
    var axis: FlipAxis = .x
    /// Preview override. When set, the component renders that point of the
    /// reveal instead of driving its own clock.
    var progress: Double? = nil

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

    private var current: Double { progress ?? animated }

    private var units: [String] {
        switch unit {
        case .character: return text.map(String.init)
        case .word: return text.split(separator: " ").map(String.init)
        case .line: return text.split(separator: "\n").map(String.init)
        }
    }

    /// Total timeline length in milliseconds: the last unit's window ends
    /// exactly at `stagger * (n - 1) + duration`.
    private var totalMs: Double {
        stagger * Double(max(units.count - 1, 0)) + duration
    }

    var body: some View {
        let content = Group {
            if unit == .line {
                VStack(alignment: .leading, spacing: 4) { unitViews }
            } else {
                FlowLayout(spacing: unit == .word ? 8 : 2) { unitViews }
            }
        }
        content
            .onAppear(perform: start)
            .accessibilityElement(children: .ignore)
            .accessibilityLabel(text)
    }

    @ViewBuilder private var unitViews: some View {
        ForEach(Array(units.enumerated()), id: \.offset) { i, piece in
            let windowStart = stagger * Double(i)
            let elapsedMs = (reduceMotion && progress == nil) ? totalMs : current * totalMs
            let rawLocal = ((elapsedMs - windowStart) / duration).clamped(to: 0...1)
            let eased = hingeEase(rawLocal)

            Text(piece)
                .opacity(min(rawLocal / 0.4, 1))
                .rotation3DEffect(
                    .degrees(-92 + eased * 92),
                    axis: axis == .x ? (x: 1, y: 0, z: 0) : (x: 0, y: 1, z: 0),
                    anchor: .center,
                    anchorZ: 0,
                    perspective: perspective
                )
                .frame(minHeight: 1, alignment: .center)
        }
    }

    /// Closed-form ease matching a lightly underdamped spring — about 6° of
    /// overshoot past flat on the default 92° arc. A formula rather than a
    /// live `.spring()` animation, so the exact same frame renders whether
    /// driven live or scrubbed to a static `progress`.
    private func hingeEase(_ t: Double) -> Double {
        let c1 = 1.3
        let c3 = c1 + 1
        let u = t - 1
        return 1 + c3 * u * u * u + c1 * u * u
    }

    private func start() {
        guard progress == nil, !reduceMotion else { return }
        withAnimation(.linear(duration: totalMs / 1000)) {
            animated = 1
        }
    }
}

enum FlipUnit: String { case character, word, line }
enum FlipAxis: String { case x, y }

private extension Comparable {
    func clamped(to range: ClosedRange<Self>) -> Self {
        min(max(self, range.lowerBound), range.upperBound)
    }
}

/// Wraps content left-to-right, moving to a new line when it would overflow
/// the proposed width. Iterative on iOS 16+ `Layout`; used by any
/// per-character component that must still line-break normally.
private struct FlowLayout: Layout {
    var spacing: CGFloat = 4
    var lineSpacing: CGFloat = 6

    func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
        let maxWidth = proposal.width ?? .infinity
        var origin = CGPoint.zero
        var lineHeight: CGFloat = 0
        var totalHeight: CGFloat = 0

        for subview in subviews {
            let size = subview.sizeThatFits(.unspecified)
            if origin.x + size.width > maxWidth, origin.x > 0 {
                origin.x = 0
                origin.y += lineHeight + lineSpacing
                totalHeight = origin.y
                lineHeight = 0
            }
            origin.x += size.width + spacing
            lineHeight = max(lineHeight, size.height)
        }
        totalHeight += lineHeight
        return CGSize(width: maxWidth.isFinite ? maxWidth : origin.x, height: totalHeight)
    }

    func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
        let maxWidth = bounds.width
        var origin = CGPoint(x: bounds.minX, y: bounds.minY)
        var lineHeight: CGFloat = 0

        for subview in subviews {
            let size = subview.sizeThatFits(.unspecified)
            if origin.x + size.width > bounds.minX + maxWidth, origin.x > bounds.minX {
                origin.x = bounds.minX
                origin.y += lineHeight + lineSpacing
                lineHeight = 0
            }
            subview.place(at: origin, anchor: .topLeading, proposal: .unspecified)
            origin.x += size.width + spacing
            lineHeight = max(lineHeight, size.height)
        }
    }
}

#Preview {
    FlipText3D()
        .font(.system(size: 34, weight: .bold))
        .frame(width: 340, height: 140)
}
iOS 17 · No dependencies

SwiftUI note. The hinge overshoot is a closed-form ease applied to each unit's local progress, not a live `.spring()`, so a scrubbed `progress` value always renders the exact same frame. `unit: line` at `stagger: 140` is the departure-board row effect.

Dependencies

SwiftUI
No external dependencies

Requires iOS 17

Flutter
No external dependencies

Requires Flutter 3.27