Skip to content
Brok UI

Loading…

No results

Flip Board

Open source

Mechanical split-flap displays — a departure-board text panel and a clock/countdown of flipping digits.

Version
v1.1.3
Stability
stable
License
MIT
Related
Flip Clock
1 of 2

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

Size
Variant
previews.components.split-flap.default.blade.php Blade
<x-ui.split-flap :words="['DEPARTURES', 'ON TIME', 'WELCOME']" length="10" />

Size options

Sm Current
Card Current
Gap Current
Md Current
Lg Current
Xl Current

Variant options

Default Current
Secondary Current
Destructive Current
Outline Current
Muted Current

Installation

terminal
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:

resources/js/ui/index.js JS
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.

  • blade resources/views/components/ui/split-flap.blade.php
  • js resources/js/ui/split-flap.js
Registry dependencies
None — installs on its own.
Packages
composer: jml/brok:^0.2
npm: 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.

split-flap.md
# 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

manifest knowledge + registry-derived coverage

Props

Props accepted by this component: name, type, default value and description.
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.

split-flap

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

Expressive and showcase effect

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
root
Theming hooks
split-flap

Accessibility

WCAG 2.2 AA Keyboard focus-visible RTL-ready Localized labels Dark mode
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-slot attribute for styling and scripting hooks.
  • Focus-visible rings use the ring token, 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 under dir="rtl" — flip the preview to RTL to confirm.
  • Dark mode uses the same semantic tokens under the dark class; high contrast follows forced-color system tokens.

Livewire

Needs wire:key

Add a stable wire:key when Livewire can reorder this interactive component.

livewire-component.blade.php Blade
<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.

resources/views/components/ui/split-flap.blade.php Blade
@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>
resources/js/ui/split-flap.js JS
/**
 * 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