
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
- Flutter
- No external dependencies
Requires iOS 17
Requires Flutter 3.27


