Skip to content
Split Flap preview
An animated render of the SwiftUI source on this page.

Split Flap

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

A departure-board that clatters through glyphs until it lands on the word.

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

  • text
  • split flap
  • mechanical
  • board
  • flip
  • retro
  • animation

The actual source

SplitFlap.swift
// Split Flap · syxUI · https://syxui.dev/components/split-flap
// Free in any project. Keep this line and credit syxUI where a person can read it.

import SwiftUI

/// The glyph set a cell cycles through on its way to its target.
///
/// Every set opens with a leading space — the idle, powered-down glyph every
/// cell rests on before it starts flapping.
enum SplitFlapCharset: Sendable {
    case alnum
    case digits
    case alnumSymbols

    var characters: [Character] {
        switch self {
        case .alnum:
            return Array(" 0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ")
        case .digits:
            return Array(" 0123456789")
        case .alnumSymbols:
            return Array(" 0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ.,:-!?")
        }
    }
}

/// A departure-board cell mid-flap: which two glyphs are involved, and how
/// far the leaf has rotated.
private struct FlapState {
    var top: Character
    var bottom: Character
    /// Degrees. 0 is flat. Negative is the outgoing leaf folding away;
    /// positive is the incoming leaf still descending.
    var angle: Double
    /// The shadow the descending leaf casts on itself, 0...0.35.
    var shade: Double
}

/// A mechanical departure-board: every cell clatters through its charset one
/// glyph at a time until it reaches the target character.
///
/// The glyph *sequence* is discrete, not a continuous property, so this is a
/// `TimelineView(.animation)` component rather than a `withAnimation` one —
/// there is no animatable curve between "P" and "Q", only a series of steps.
/// Each cell only ever draws two clipped halves: the settled character
/// underneath, and the flapping leaf on top of it. The leaf is edge-on
/// (invisible) exactly when the settled half swaps from the outgoing glyph to
/// the incoming one, which is what makes the swap look instantaneous instead
/// of like a cut.
struct SplitFlap: View {
    var text: String = "DEPARTING"
    var charset: SplitFlapCharset = .alnum
    /// Milliseconds for one leaf to complete its fold-away-and-land.
    var flapDuration: Double = 200
    /// Milliseconds between neighbouring cells starting to flap.
    var stagger: Double = 55
    var cellColor: Color = Color(red: 0.086, green: 0.094, blue: 0.114)
    var textColor: Color = Color(red: 0.949, green: 0.953, blue: 0.961)
    var fontSize: CGFloat = 34

    /// Preview override. When set, the board renders that point of one
    /// complete cascade instead of running its own clock.
    var progress: Double? = nil

    @Environment(\.accessibilityReduceMotion) private var reduceMotion
    @State private var startDate: Date? = nil

    private var cellWidth: CGFloat { fontSize * 0.82 }
    private var cellHeight: CGFloat { fontSize * 1.3 }
    private var glyphs: [Character] { Array(text.uppercased()) }

    var body: some View {
        Group {
            if let progress {
                board(elapsedMs: progress * totalDurationMs)
            } else if reduceMotion {
                board(elapsedMs: totalDurationMs)
            } else {
                TimelineView(.animation(paused: settledSincePowerOn)) { timeline in
                    let elapsed = startDate.map { timeline.date.timeIntervalSince($0) * 1000 } ?? 0
                    board(elapsedMs: elapsed)
                }
            }
        }
        .onAppear { if startDate == nil { startDate = Date() } }
        // The board narrates every intermediate glyph if VoiceOver is left to
        // its own devices; it should read the finished word once.
        .accessibilityElement(children: .ignore)
        .accessibilityLabel(text)
    }

    /// Stops the timeline once every cell has landed, rather than redrawing a
    /// static board sixty times a second forever.
    private var settledSincePowerOn: Bool {
        guard let startDate else { return false }
        return Date().timeIntervalSince(startDate) * 1000 > totalDurationMs + 200
    }

    private func board(elapsedMs: Double) -> some View {
        HStack(spacing: 4) {
            ForEach(Array(glyphs.enumerated()), id: \.offset) { index, glyph in
                cell(target: glyph, order: index, elapsedMs: elapsedMs)
            }
        }
    }

    private func cell(target: Character, order: Int, elapsedMs: Double) -> some View {
        let state = flapState(target: target, order: order, elapsedMs: elapsedMs)
        return VStack(spacing: 1) {
            half(state.top, top: true)
                .rotation3DEffect(
                    .degrees(state.angle), axis: (x: 1, y: 0, z: 0),
                    anchor: .bottom, perspective: 0.4
                )
                .overlay(Color.black.opacity(state.shade))
            half(state.bottom, top: false)
        }
        .foregroundStyle(textColor)
        .font(.system(size: fontSize, weight: .bold, design: .monospaced))
        .frame(width: cellWidth, height: cellHeight)
        .background(cellColor)
        .clipShape(RoundedRectangle(cornerRadius: 4))
    }

    /// Draws the full glyph into a full-height frame, then clips to one half
    /// — the top and bottom halves of the same character line up perfectly
    /// because they come from the same laid-out `Text`.
    private func half(_ ch: Character, top: Bool) -> some View {
        Text(String(ch))
            .frame(width: cellWidth, height: cellHeight)
            .frame(height: cellHeight / 2, alignment: top ? .top : .bottom)
            .clipped()
    }

    /// How many glyphs of `charset` sit between the idle blank at index 0 and
    /// `target`. Every cell starts blank, so this is also its flap count.
    private func steps(to target: Character) -> Int {
        charset.characters.firstIndex(of: target) ?? 0
    }

    private func flapState(target: Character, order: Int, elapsedMs: Double) -> FlapState {
        let sequence = charset.characters
        guard let targetIndex = sequence.firstIndex(of: target) else {
            return FlapState(top: target, bottom: target, angle: 0, shade: 0)
        }

        let cellDelay = Double(order) * stagger
        let localElapsed = max(0, elapsedMs - cellDelay)
        let flapIndex = min(Int(localElapsed / max(flapDuration, 1)), targetIndex)

        guard flapIndex < targetIndex else {
            return FlapState(top: target, bottom: target, angle: 0, shade: 0)
        }

        let flapElapsed = localElapsed - Double(flapIndex) * flapDuration
        let flapProgress = min(max(flapElapsed / max(flapDuration, 1), 0), 1)

        let outgoing = sequence[flapIndex]
        let incoming = sequence[flapIndex + 1]

        // 90ms of a 200ms flap is the fold-away; scale that ratio rather than
        // the raw millisecond count so a `flapDuration` slider stays
        // proportioned instead of degenerating at its extremes.
        let foldAway = 0.45
        if flapProgress < foldAway {
            let t = flapProgress / foldAway
            return FlapState(top: outgoing, bottom: outgoing, angle: -90 * (t * t), shade: 0)
        } else {
            let t = (flapProgress - foldAway) / (1 - foldAway)
            let landed = 1 - (1 - t) * (1 - t)
            return FlapState(
                top: incoming, bottom: incoming,
                angle: 90 * (1 - landed),
                shade: sin(t * .pi) * 0.35
            )
        }
    }

    /// Longest cascade across every cell: its stagger delay plus its own
    /// flap count. Cells nearer the front of the charset finish sooner.
    private var totalDurationMs: Double {
        glyphs.enumerated()
            .map { index, glyph in Double(index) * stagger + Double(steps(to: glyph)) * flapDuration }
            .max() ?? 0
    }
}

#Preview {
    SplitFlap()
        .padding(24)
}
iOS 17 · No dependencies

SwiftUI note. The glyph sequence is discrete, so this runs on `TimelineView(.animation)` rather than an implicit animation, and it pauses itself once every cell has landed. Raise `stagger` before `flapDuration` if you want a calmer board — it reads the cascade, not the mechanism.

Dependencies

SwiftUI
No external dependencies

Requires iOS 17

Flutter
No external dependencies

Requires Flutter 3.27