Flip Board
Mechanical split-flap displays — a departure-board text panel and a clock/countdown of flipping digits.
Split Flap — An airport/train split-flap departure board that mechanically flips each character cell through the charset until it reaches its target glyph, reusing the flip-clock flap animation.
Preview
<x-ui.split-flap :words="['DEPARTURES', 'ON TIME', 'WELCOME']" length="10" />
Size options
Variant options
Installation
php artisan ui:add split-flap
Note
This component ships an Alpine behavior module at
resources/js/ui/split-flap.js. Import it once from your bundle so it registers on alpine:init:
import './split-flap.js';
Registry contract
php artisan ui:add split-flap
writes only the files below. The CLI validates each file hash before writing and asks before it replaces a local change, unless you pass --force.
-
resources/views/components/ui/split-flap.blade.php -
resources/js/ui/split-flap.js
- Registry dependencies
- None — installs on its own.
- Packages
-
composer: jml/brok:^0.2npm: alpinejs
Use with AI
A brief for your coding agent: install command, usage, props, guidance and the rules. Copy it, or open a prompt about this component in an assistant.
# Brok UI: Split Flap (`split-flap`)
An airport/train split-flap departure board that mechanically flips each character cell through the charset until it reaches its target glyph, reusing the flip-clock flap animation.
Brok UI is a Laravel Blade component registry. Installed components are plain Blade files the app owns.
## Install
```bash
php artisan ui:add split-flap
```
## Usage
```blade
<x-ui.split-flap :words="['DEPARTURES', 'ON TIME', 'WELCOME']" length="10" />
```
## Props
- `value` (string, default ``) — Single text value to display; overridden by words when supplied.
- `words` (mixed|null, default `null`) — Array of words to cycle through in sequence; overrides value when non-empty.
- `length` (mixed|null, default `null`) — Fixed cell count per word; null sizes each word to its own length.
- `charset` (string, default `alphanumeric`) — Character set each cell flips through en route to its target glyph.
- `size` (md|sm|card|gap|lg|xl, default `md`) — Tile size: sm, md, lg, or xl.
- `variant` (default|secondary|destructive|outline|muted, default `default`) — Tile colour: default, secondary, destructive, outline, or muted.
- `cycleMs` (int, default `2500`) — Milliseconds each word is shown before cycling to the next.
- `flipMs` (int, default `60`) — Milliseconds for each individual flap flip.
## Use when
- Use only when visual expression supports storytelling, brand differentiation, or a clear showcase moment.
- Displaying a value or cycling word list as an airport/train departure-board split-flap animation.
- Reusing the flip-clock mechanical flap look for arbitrary alphanumeric text instead of a clock.
## Avoid when
- Do not place heavy effects in task-dense workflows where clarity, speed, and readability matter more.
- Displaying a live clock or countdown; use flip-clock instead, which is purpose-built for time units.
- Needing plain, instantly readable text; the mechanical flip-through takes longer to read than static text.
## Anti-patterns
- Heavy effects in forms or task-dense workflows
- Motion that interferes with reading or focus
## Rules
- Use the `<brok:split-flap>` tag (or `<x-ui.split-flap>`) in Blade; do not rewrite the component.
- Prefer the documented props and variants over utility-class overrides; when a utility must win, use the `!` important modifier.
- Use semantic design tokens (`bg-primary`, `text-muted-foreground`), never raw colour utilities.
- Keep the `data-slot` attributes; they are the styling and test hooks.
## Links
- Docs: https://brokui.dev/docs/components/split-flap
- Registry JSON (files, props, contract): https://brokui.dev/r/open/split-flap.json
Working in Claude Code, Cursor or Codex? Give the agent the whole registry through the MCP server or the Brok UI skill.
API
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| value | string | Single text value to display; overridden by words when supplied. | |
| words | mixed | null | null | Array of words to cycle through in sequence; overrides value when non-empty. |
| length | mixed | null | null | Fixed cell count per word; null sizes each word to its own length. |
| charset | string | alphanumeric | Character set each cell flips through en route to its target glyph. |
| size | md | sm | card | gap | lg | xl | md | Tile size: sm, md, lg, or xl. |
| variant | default | secondary | destructive | outline | muted | default | Tile colour: default, secondary, destructive, outline, or muted. |
| cycleMs | int | 2500 | Milliseconds each word is shown before cycling to the next. |
| flipMs | int | 60 | Milliseconds for each individual flap flip. |
Slots
default— Unused; the component has no visible default slot content.
Data slots
Stable hooks for CSS overrides and browser tests.
Behavior
- Each character cell mechanically flips through the charset one glyph at a time until it lands on its target character, reusing the flip-clock flap animation.
- When words has more than one entry, the board cycles through them automatically every cycleMs; a single value or word just settles and stays.
- Installs a JavaScript behavior module when the registry item includes resources/js/ui files.
- Declares registry capability flags: a11y, interactive, responsive, rtl, darkMode, localized, reducedMotion.
Guidance
Support brand expression or a showcase moment.
Use when
- Use only when visual expression supports storytelling, brand differentiation, or a clear showcase moment.
- Displaying a value or cycling word list as an airport/train departure-board split-flap animation.
- Reusing the flip-clock mechanical flap look for arbitrary alphanumeric text instead of a clock.
Avoid when
- Do not place heavy effects in task-dense workflows where clarity, speed, and readability matter more.
- Displaying a live clock or countdown; use flip-clock instead, which is purpose-built for time units.
- Needing plain, instantly readable text; the mechanical flip-through takes longer to read than static text.
Use instead
- Static content-first presentation
Anti-patterns
- Heavy effects in forms or task-dense workflows
- Motion that interferes with reading or focus
- Anatomy
- Theming hooks
Accessibility
- Keyboard
- managed
- Focus
none
- The root carries role="img" with a live aria-label bound to the resolved text, so screen readers get the current word, not the intermediate flip glyphs.
- Individual flap cells are aria-hidden since the label already conveys the full text.
- Semantic HTML and a stable
data-slotattribute for styling and scripting hooks. - Focus-visible rings use the
ringtoken, so keyboard focus is always visible. - Disabled and invalid states are conveyed to assistive tech, not by color alone.
- Targets WCAG 2.2 AA; verify contrast in light, dark, admin and customer surfaces in the preview.
- Labels go through
__()and layout uses logical properties (ms-*,text-start), so it mirrors underdir="rtl"— flip the preview to RTL to confirm. - Dark mode uses the same semantic tokens under the
darkclass; high contrast follows forced-color system tokens.
Livewire
Add a stable wire:key when Livewire can reorder this interactive component.
<div wire:key="split-flap-{{ $record->id }}">
<x-ui.split-flap :words="['DEPARTURES', 'ON TIME', 'WELCOME']" length="10" />
</div>
Source
The exact, editable files ui:add writes
into your app. Previews render this same code; there are no preview-only components.
@props([
'value' => '',
'words' => null,
'length' => null,
'charset' => 'alphanumeric',
'size' => 'md',
'variant' => 'default',
'cycleMs' => 2500,
'flipMs' => 60,
])
@php
$styles = require base_path(config('ui.component_path', 'resources/views/components/ui').'/_styles.php');
$variant = $styles['normalizeVariant']($variant);
$sizes = [
'sm' => ['card' => 'h-10 w-7 text-xl', 'gap' => 'gap-0.5'],
'md' => ['card' => 'h-14 w-10 text-3xl', 'gap' => 'gap-1'],
'lg' => ['card' => 'h-20 w-14 text-5xl', 'gap' => 'gap-2'],
'xl' => ['card' => 'h-28 w-20 text-7xl', 'gap' => 'gap-2'],
];
$variants = [
'default' => 'bg-primary text-primary-foreground',
'secondary' => 'bg-secondary text-secondary-foreground',
'destructive' => 'bg-destructive text-destructive-foreground',
'outline' => 'border border-border bg-background text-foreground',
'muted' => 'bg-muted text-foreground',
];
$s = $sizes[$size] ?? $sizes['md'];
$variantClass = $variants[$variant] ?? $variants['default'];
// The split-flap structure lives in ui.css (.ui-flipcard*). The four layers
// inherit this card's background-color and text colour.
$cardClass = 'ui-flipcard rounded-md font-semibold tabular-nums uppercase shadow-sm '
.$s['card'].' '.$variantClass;
// Resolve the word list: words array overrides the single value.
$resolvedWords = is_array($words) && count($words)
? array_values(array_map('strval', $words))
: [(string) $value];
$config = [
'words' => $resolvedWords,
'length' => $length !== null ? (int) $length : null,
'charset' => (string) $charset,
'cycleMs' => (int) $cycleMs,
'flipMs' => (int) $flipMs,
];
@endphp
<div
x-data="uiSplitFlap(@js($config))"
data-slot="split-flap"
data-variant="{{ $variant }}"
role="img"
:aria-label="label"
dir="ltr"
{{ $attributes->merge(['class' => 'inline-flex items-center '.$s['gap']]) }}
>
<template x-for="(cell, i) in cells" :key="i">
<span class="{{ $cardClass }}" :class="cell.flipping && 'is-flipping'" aria-hidden="true">
{{-- Static halves: new top is revealed as the flap folds; the
old bottom stays until the new bottom flap covers it. --}}
<span class="ui-flipcard__half ui-flipcard__top"><span class="ui-flipcard__digit" x-text="glyph(cell.index)"></span></span>
<span class="ui-flipcard__half ui-flipcard__bottom"><span class="ui-flipcard__digit" x-text="glyph(cell.flipping ? cell.prev : cell.index)"></span></span>
{{-- Animating flaps, only present mid-flip. --}}
<template x-if="cell.flipping">
<span class="ui-flipcard__flap ui-flipcard__flap--top"><span class="ui-flipcard__digit" x-text="glyph(cell.prev)"></span></span>
</template>
<template x-if="cell.flipping">
<span class="ui-flipcard__flap ui-flipcard__flap--bottom"><span class="ui-flipcard__digit" x-text="glyph(cell.index)"></span></span>
</template>
</span>
</template>
</div>
/**
* Split-flap (departure board) behavior.
*
* Renders a row of `.ui-flipcard` cells (the same flap structure as flip-clock)
* that mechanically sweep through the charset until each cell reaches its
* target glyph — the classic airport/train cascade. Each step swaps the
* displayed glyph at the edge-on frame (CSS keyframe in ui.css), exactly like
* flip-clock. When a list of words is supplied the board settles, pauses
* `cycleMs`, then targets the next word and loops.
*
* Reduced-motion users see the final target text statically: cells jump
* directly to the target index with no stepping and no flip class.
*
* Self-registers on `alpine:init` so import order does not matter.
*/
document.addEventListener('alpine:init', () => {
window.Alpine.data('uiSplitFlap', (config = {}) => ({
words: Array.isArray(config.words) && config.words.length ? config.words : [''],
flipMs: config.flipMs ?? 60,
cycleMs: config.cycleMs ?? 2500,
chars: '',
length: 0,
cells: [],
label: '',
wordIndex: 0,
stepTimer: null,
cycleTimer: null,
reduced: false,
init() {
this.chars = this.resolveCharset(config.charset);
this.length = config.length != null
? Math.max(0, config.length)
: this.words.reduce((m, w) => Math.max(m, w.length), 0);
this.reduced = window.matchMedia
&& window.matchMedia('(prefers-reduced-motion: reduce)').matches;
// Build cells, all parked at the space/first index.
this.cells = Array.from({ length: this.length }, () => ({
index: 0,
prev: 0,
flipping: false,
}));
this.setTarget(this.words[0]);
},
destroy() {
if (this.stepTimer) clearInterval(this.stepTimer);
if (this.cycleTimer) clearTimeout(this.cycleTimer);
},
// 'alphanumeric' = space + A–Z + 0–9; 'letters' = space + A–Z;
// 'digits' = 0–9; otherwise treat the value as a literal glyph string.
resolveCharset(charset) {
const A = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
const D = '0123456789';
switch (charset) {
case 'alphanumeric': return ' ' + A + D;
case 'letters': return ' ' + A;
case 'digits': return D;
default: return (charset && charset.length) ? charset : ' ' + A + D;
}
},
glyph(i) {
return this.chars.charAt(i) || ' ';
},
// Map a character to its charset index (uppercased); unknown → space
// (index of ' ', or 0 when the charset has no space).
indexOf(ch) {
const i = this.chars.indexOf(ch.toUpperCase());
if (i !== -1) return i;
const sp = this.chars.indexOf(' ');
return sp !== -1 ? sp : 0;
},
// Resolve the target text into a per-cell target index. Shorter strings
// are space-padded on the right.
setTarget(str) {
const text = (str ?? '').toString();
this.label = text;
const targets = [];
for (let i = 0; i < this.length; i++) {
targets.push(this.indexOf(text.charAt(i) || ' '));
}
if (this.reduced) {
// Jump straight to target — no stepping, no flip class.
this.cells.forEach((cell, i) => {
cell.index = targets[i];
cell.prev = targets[i];
cell.flipping = false;
});
this.scheduleNext();
return;
}
this.cells.forEach((cell, i) => { cell.target = targets[i]; });
this.startStepping();
},
startStepping() {
if (this.stepTimer) clearInterval(this.stepTimer);
this.stepTimer = setInterval(() => this.step(), this.flipMs);
},
step() {
let moving = false;
this.cells.forEach((cell) => {
if (cell.index === cell.target) return;
moving = true;
const next = (cell.index + 1) % this.chars.length;
// prev holds the old glyph index for the folding flaps; index is
// the new one (static top + descending bottom flap). CSS does the
// visual swap, so no mid-flight mutation is needed.
cell.prev = cell.index;
cell.index = next;
cell.flipping = true;
setTimeout(() => { cell.flipping = false; cell.prev = cell.index; }, 600);
});
if (!moving) {
if (this.stepTimer) clearInterval(this.stepTimer);
this.stepTimer = null;
this.scheduleNext();
}
},
// After the board settles, advance to the next word (if more than one).
scheduleNext() {
if (this.words.length < 2) return;
if (this.cycleTimer) clearTimeout(this.cycleTimer);
this.cycleTimer = setTimeout(() => {
this.wordIndex = (this.wordIndex + 1) % this.words.length;
this.setTarget(this.words[this.wordIndex]);
}, this.cycleMs);
},
}));
});
Ownership & lifecycle
Owner, release state, review evidence and adoption for this item.
- Owner
- Platform UI (@JoshJML)
- Current version
-
1.1.3 - Status
- Stable
- License
-
open - Accessibility reviewed
- No review date recorded
- Last breaking change
- No date recorded
- Deprecation
- Not deprecated
- Contract
-
v6 - Foundation
-
≥ 1.0.0