Split-flap departure-board text for SwiftUI and UIKit — every letter rolls, flips or flickers into place, played by Core Animation
import SplitflapKit
Splitflap(gate)
.splitflapTransition(.flip(surface: .black))
.splitflapFont(size: 28, weight: .bold)- 🎰 Four transitions —
flip,reel,rollandscramble, for words and for numbers - 🔤 Any text, any script — Latin, Cyrillic, Greek, Armenian, Georgian, the Indic and Southeast Asian scripts, the kana and many numeral systems roll through their own alphabets; any other character still changes, in one step. Flags, emoji and Indic syllables stay whole in one cell
- 📏 Cells that keep still — tabular digits by default, and two cell widths: text-like
naturalanduniform, a station board's fixed cells - ⚡ Core Animation — every glyph is rasterised once into an atlas and every change is baked into keyframes, so the motion runs in the render server, not in your code
- 🔁 Interruptible — a new text mid-flight continues from where every cell is, without a jump
- ⏳ Loading built in — random words spin while data loads, then the real text lands
- ♿ Accessible — one static-text element with the full text, Dynamic Type and Reduce Motion
- 📐 Lays out like a label — intrinsic size of a one-line
UILabelin the same font,…when narrower - 🧩 SwiftUI and UIKit —
SplitflapandSplitflapView, iPhone, iPad and the Mac through Mac Catalyst, no dependencies
Swift Package Manager — in Xcode, File → Add Package Dependencies… and paste:
https://github.com/enavermate/SplitflapKit
or in Package.swift:
.package(url: "https://github.com/enavermate/SplitflapKit", from: "0.5.3")iOS and iPadOS 15 or later, and Mac Catalyst 15 or later.
SwiftUI
struct Gate: View {
@State private var status = "BOARDING"
var body: some View {
Splitflap(status)
.splitflapTransition(.flip(surface: .black))
.splitflapCellWidth(.uniform)
.splitflapFont(size: 32, weight: .bold, design: .monospaced)
.splitflapColor(.yellow)
}
}A new string turns in from the one on screen. Modifiers apply to every Splitflap below them, so a
whole board takes one .splitflapTransition.
UIKit
let board = SplitflapView(text: "MADRID")
board.font = .systemFont(ofSize: 32, weight: .bold)
board.transition = .roll()
view.addSubview(board) // Auto Layout: it has an intrinsic size, like a label
board.text = "LISBOA"A departure board: one transition for the whole board
Modifiers flow down to every Splitflap inside, and the nearest one wins, so a column can still take its own colour.
struct Departures: View {
let flights: [(time: String, city: String, status: String)]
var body: some View {
VStack(alignment: .leading, spacing: 6) {
ForEach(flights, id: \.city) { flight in
HStack(spacing: 12) {
Splitflap(flight.time)
Splitflap(flight.city)
Splitflap(flight.status).splitflapColor(.yellow)
}
}
}
.splitflapTransition(.flip(surface: .black))
.splitflapCellWidth(.uniform)
.splitflapFont(size: 20, weight: .bold, design: .monospaced)
.splitflapColor(.white)
}
}A price that holds still
Digits are tabular by default, so the price keeps its width; .auto rolls each digit the short way round.
struct Price: View {
let amount: Decimal
var body: some View {
Splitflap(amount.formatted(.currency(code: "USD")))
.splitflapTransition(.roll(direction: .auto))
.splitflapFont(size: 34, weight: .semibold)
}
}A clock
struct Clock: View {
var body: some View {
TimelineView(.periodic(from: .now, by: 1)) { context in
Splitflap(context.date.formatted(date: .omitted, time: .standard))
.splitflapTransition(.reel(direction: .down))
.splitflapCellWidth(.uniform)
}
}
}Loading words instead of a skeleton
Random words spin while the data loads, then the real text lands. The range keeps the loading words near the size of what comes.
struct Greeting: View {
let name: String?
var body: some View {
Splitflap(name ?? "")
.splitflapLoading(name == nil, length: .range(min: 4, max: 10))
.splitflapTransition(.flip(surface: .black))
}
}Hex codes, a custom alphabet and timing
struct Status: View {
let code: String
var body: some View {
Splitflap(code)
.splitflapTransition(.scramble)
.splitflapAlphabet(.letters("0123456789ABCDEF"))
.splitflapTiming(duration: 0.4, stagger: 0.02, order: .random)
}
}One script for every cell
By default each letter rolls through its own script's alphabet, read from the text; name a script to fix it.
struct City: View {
let name: String
var body: some View {
Splitflap(name)
.splitflapAlphabet(.script(.greek))
}
}Rows in a list
A reused row replans when its key changes, and shows its first text at once instead of animating in.
struct Row: View {
let item: (id: Int, title: String)
var body: some View {
Splitflap(item.title)
.splitflapContentKey(item.id)
.splitflapAnimatesOnAppear(false)
}
}Knowing when a change starts and settles
struct Score: View {
let score: Int
@State private var settled = true
var body: some View {
Splitflap(String(score))
.onSplitflapTransitionStart { _ in settled = false }
.onSplitflapTransitionEnd { _, interrupted in
if !interrupted { settled = true }
}
.opacity(settled ? 1 : 0.8)
}
}Motion, randomness and tracking
.never keeps the motion even with Reduce Motion on — for a decorative banner only. The same seed always plays the same way.
struct Banner: View {
var body: some View {
Splitflap("SALE")
.splitflapReduceMotion(.never)
.splitflapSeed(7)
.splitflapLetterSpacing(2)
}
}UIKit: a view controller with loading
SplitflapView sizes itself like a one-line UILabel, so Auto Layout needs only a position.
final class GateViewController: UIViewController {
private let board = SplitflapView(text: "")
override func viewDidLoad() {
super.viewDidLoad()
board.font = .monospacedSystemFont(ofSize: 28, weight: .bold)
board.textColor = .systemYellow
board.transition = .flip(surface: .black)
board.cellWidth = .uniform
board.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(board)
NSLayoutConstraint.activate([
board.centerXAnchor.constraint(equalTo: view.centerXAnchor),
board.centerYAnchor.constraint(equalTo: view.centerYAnchor),
])
board.isLoading = true
board.loadingLength = .cells(6)
board.onTransitionEnd = { text, interrupted in
print("settled on \(text)", interrupted ? "(interrupted)" : "")
}
}
func show(gate: String) {
board.isLoading = false
board.text = gate
}
}The planner, without a view
SplitflapPlanner (re-exported by SplitflapKit) returns every cell's path and timing as plain data — for tests, or to draw the board some other way.
func plannedPaths() {
let first = planTransition(createBoard(), TransitionInput(text: "MADRID", now: 0, instant: true))
let next = planTransition(first.board, TransitionInput(text: "LISBOA", now: 1000, transition: .flip))
for cell in next.plan.cells {
print(cell.from, "→", cell.to, cell.path.joined(), "\(cell.delayMs) ms")
}
}| Transition | What every changed letter does |
|---|---|
.reel() (default) |
Spins through the alphabet between the old letter and the new one, like a slot machine reel |
.roll() |
Rolls through a few letters: its neighbours when the new one is close, random ones when it is far |
.flip(surface:) |
Flips over in halves, like a departure board |
.scramble |
Flickers through random letters of its own script, then locks |
flip paints its flaps solid so the half they cover never shows through: surface is the colour
right under the text, and the flaps vanish into it. reel and roll take a direction:
.random (default — every other cell from above), .auto (each cell the way its letter lies in the
alphabet), .up, .down.
Splitflap(weekday).splitflapTransition(.roll(direction: .auto))SplitflapCellWidth |
Behaviour |
|---|---|
.natural |
A cell moves from the old glyph's width to the new one's, as text reflows. reel and roll only |
.uniform |
Every cell as wide as the alphabet's widest letter, each glyph centred — a station board |
Without one, reel and roll reflow like text, and flip and scramble keep every letter close to
its own width — narrow, regular and wide cells measured from the text's alphabets — since they show
two glyphs in one cell at once and must not reflow.
SwiftUI modifiers
| Modifier | What it sets |
|---|---|
.splitflapTransition(_:) |
.reel(), .roll(), .flip(surface:), .scramble |
.splitflapCellWidth(_:) |
.natural (reel, roll), .uniform; nil for the default |
.splitflapTiming(duration:stagger:order:) |
seconds per cell (0.65), seconds between cells (0.04), .ltr .rtl .random |
.splitflapAlphabet(_:) |
.auto, .script(.greek), .letters("0123456789ABCDEF") |
.splitflapLoading(_:length:) |
random words until false; .cells(4) or .range(min: 4, max: 10) |
.splitflapFont(_:), .splitflapFont(size:weight:design:) |
the glyphs' font, scaled with Dynamic Type |
.splitflapColor(_:), .splitflapLetterSpacing(_:), .splitflapTabularDigits(_:) |
colour, tracking, digits of one width (on) |
.splitflapSeed(_:) |
the same seed plays the same way |
.splitflapContentKey(_:), .splitflapAnimatesOnAppear(_:) |
lists: replan a reused row, show the first text at once |
.splitflapReduceMotion(_:) |
.system (default), .always, .never |
.onSplitflapTransitionStart(_:), .onSplitflapTransitionEnd(_:) |
the text, and whether a newer text interrupted it |
UIKit — SplitflapView has the same settings as properties: text, transition, cellWidth,
duration, stagger, staggerOrder, alphabet, isLoading, loadingLength, seed, font,
textColor, letterSpacing, lineHeight, usesTabularDigits, adjustsFontForContentSizeCategory,
reduceMotion, animatesOnAppear, contentKey, onTransitionStart, onTransitionEnd.
The planner — createBoard(), planTransition(_:_:) and loadingPlan(_:seed:options:) return
every cell's path, timing and direction as plain data, so a board's behaviour can be tested without
rendering, or drawn some other way.
A cell rolls through the alphabet of its own letter's script, read from the text — no language setting. A word travels only through the core letters and the extra letters it contains, so a Ukrainian word never flashes a letter only Belarusian uses. Chinese, Korean and anything else without an alphabet changes in one step: no script can break a board. Right-to-left scripts are not supported.
The same board, plan for plan:
react-native-splitflap for React Native and Expo
(npx expo install @enavermate/react-native-splitflap), and
splitflap for the web — a <split-flap> element and a React
component (npm install @enavermate/splitflap). SplitflapKit's planner is a line-by-line port checked
against 131 scenarios of the TypeScript one, so a text animates the same way in all three. How each is
built: scivi.dev/oss/splitflap.
Examples/SplitflapDemo — open SplitflapDemo.xcodeproj and run it on an iPhone, an iPad or, as a Mac Catalyst app, on the Mac.
Found a bug, or missing something? Open an issue. See CONTRIBUTING; everyone taking part follows the code of conduct.
MIT © Roman Diukachov