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.2.1
Stability
stable
License
MIT
Related
Split Flap

Flip Clock — A split-flap clock or countdown with size and variant options and an auto/always/never days unit.

Preview

Size
Variant
previews.components.flip-clock.default.blade.php Blade
<x-ui.flip-clock />

Size options

Sm Current
Card Current
Gap Current
Sep 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 flip-clock

Note

This component ships an Alpine behavior module at resources/js/ui/flip-clock.js. Import it once from your bundle so it registers on alpine:init:

resources/js/ui/index.js JS
import './flip-clock.js';

Registry contract

php artisan ui:add flip-clock 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/flip-clock.blade.php
  • js resources/js/ui/flip-clock.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.

flip-clock.md
# Brok UI: Flip Clock (`flip-clock`)

A split-flap clock or countdown with size and variant options and an auto/always/never days unit.

Brok UI is a Laravel Blade component registry. Installed components are plain Blade files the app owns.

## Install

```bash
php artisan ui:add flip-clock
```

## Usage

```blade
<x-ui.flip-clock />
```

## Props

- `size` (md|sm|card|gap|sep|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.
- `countdown` (bool, default `false`) — Counts down to targetDate instead of showing the current time.
- `targetDate` (mixed|null, default `null`) — Countdown target, parsed as a date/time string; required when countdown is true.
- `showDays` (string, default `auto`) — Days unit visibility: auto (only shown when days remain), always, or never.
- `labels` (mixed|null, default `null`) — Unit labels under each tile pair: true for localized Days/Hours/Minutes/Seconds, an array to override individual labels, or null/false for a bare board.

## Use when

- Use only when visual expression supports storytelling, brand differentiation, or a clear showcase moment.
- Showing the current time or a countdown to a target date as a split-flap mechanical clock.
- Wanting size and colour variant options plus an auto/always/never days unit for a countdown.

## Avoid when

- Do not place heavy effects in task-dense workflows where clarity, speed, and readability matter more.
- Showing a static, non-animated timestamp; a plain text element is lighter and does not need role="timer".
- Needing a generic split-flap text display unrelated to time; use split-flap instead.

## Anti-patterns

- Heavy effects in forms or task-dense workflows
- Motion that interferes with reading or focus

## Rules

- Use the `<brok:flip-clock>` tag (or `<x-ui.flip-clock>`) 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/flip-clock
- Registry JSON (files, props, contract): https://brokui.dev/r/open/flip-clock.json

Working in Claude Code, Cursor or Codex? Give the agent the whole registry through the MCP server or the Brok UI skill.

Examples

countdown.blade.php Blade
<x-ui.flip-clock
    :countdown="true"
    :target-date="\Illuminate\Support\Carbon::create(2027, 1, 1, 0, 0, 0)->toIso8601String()"
    show-days="always"
    variant="outline"
    size="lg"
/>

API

manifest knowledge + registry-derived coverage

Props

Props accepted by this component: name, type, default value and description.
Prop Type Default Description
size md | sm | card | gap | sep | 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.
countdown bool false Counts down to targetDate instead of showing the current time.
targetDate mixed | null null Countdown target, parsed as a date/time string; required when countdown is true.
showDays string auto Days unit visibility: auto (only shown when days remain), always, or never.
labels mixed | null null Unit labels under each tile pair: true for localized Days/Hours/Minutes/Seconds, an array to override individual labels, or null/false for a bare board.

Slots

  • default — Unused; the component has no visible default slot content.

Data slots

Stable hooks for CSS overrides and browser tests.

flip-clock flip-clock-label flip-clock-unit

Behavior

  • Each digit is an independent slot that flips with a CSS keyframe when its value changes, driven by a one-second interval timer.
  • In countdown mode, the days unit appears, stays, or never shows based on showDays; the clock always renders hours, minutes, and seconds.
  • 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.
  • Showing the current time or a countdown to a target date as a split-flap mechanical clock.
  • Wanting size and colour variant options plus an auto/always/never days unit for a countdown.

Avoid when

  • Do not place heavy effects in task-dense workflows where clarity, speed, and readability matter more.
  • Showing a static, non-animated timestamp; a plain text element is lighter and does not need role="timer".
  • Needing a generic split-flap text display unrelated to time; use split-flap instead.

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 flip-clock-unit flip-clock-label
Theming hooks
flip-clock flip-clock-label flip-clock-unit

Accessibility

WCAG 2.2 AA Keyboard focus-visible RTL-ready Localized labels Dark mode
Keyboard
managed
Focus
none
  • The root carries role="timer" with aria-live="off" so the constantly changing digits are not announced on every tick.
  • The colon separators between unit groups are decorative and marked aria-hidden.
  • 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="flip-clock-{{ $record->id }}">
    <x-ui.flip-clock />
</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/flip-clock.blade.php Blade
@props([
    'size' => 'md',
    'variant' => 'default',
    'countdown' => false,
    'targetDate' => null,
    'showDays' => 'auto',
    // Unit labels under each pair of tiles: `true` for the localized
    // Days/Hours/Minutes/Seconds, an array to override any of them, or
    // null/false for the bare board.
    'labels' => null,
])

@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', 'sep' => 'h-10 text-xl'],
        'md' => ['card' => 'h-14 w-10 text-3xl', 'gap' => 'gap-1', 'sep' => 'h-14 text-3xl'],
        'lg' => ['card' => 'h-20 w-14 text-5xl', 'gap' => 'gap-2', 'sep' => 'h-20 text-5xl'],
        'xl' => ['card' => 'h-28 w-20 text-7xl', 'gap' => 'gap-2', 'sep' => 'h-28 text-7xl'],
    ];

    $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 shadow-sm '
        .$s['card'].' '.$variantClass;

    $isoTarget = $targetDate ? \Illuminate\Support\Carbon::parse($targetDate)->toIso8601String() : null;

    $showLabels = $labels === true || is_array($labels);
    $unitLabels = $showLabels ? array_merge([
        'days' => __('Days'),
        'hours' => __('Hours'),
        'minutes' => __('Minutes'),
        'seconds' => __('Seconds'),
    ], is_array($labels) ? $labels : []) : [];
    $labelClass = match ($size) {
        'sm' => 'text-2xs',
        'lg' => 'text-sm',
        'xl' => 'text-base',
        default => 'text-xs',
    };

    $config = [
        'countdown' => (bool) $countdown,
        'targetDate' => $isoTarget,
        'showDays' => $showDays,
        'labels' => $unitLabels,
    ];
@endphp

<div
    x-data="uiFlipClock(@js($config))"
    data-slot="flip-clock"
    data-variant="{{ $variant }}"
    role="timer"
    aria-live="off"
    dir="ltr"
    {{ $attributes->merge(['class' => 'inline-flex items-start '.$s['gap']]) }}
>
    {{-- One group per unit: its digit tiles, then the optional label. The
         colon separator sits between groups, level with the tiles. --}}
    <template x-for="(unit, ui) in units" :key="unit.key">
        <div class="inline-flex items-start {{ $s['gap'] }}">
            <template x-if="ui > 0">
                <span class="inline-flex items-center px-0.5 font-semibold text-foreground {{ $s['sep'] }}" aria-hidden="true">:</span>
            </template>
            <div class="flex flex-col items-center gap-2" data-slot="flip-clock-unit" :data-unit="unit.key">
                <div class="inline-flex {{ $s['gap'] }}">
                    <template x-for="(slot, i) in unit.digits" :key="i">
                        <span class="{{ $cardClass }}" :class="slot.flipping && 'is-flipping'">
                            {{-- 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="slot.value"></span></span>
                            <span class="ui-flipcard__half ui-flipcard__bottom"><span class="ui-flipcard__digit" x-text="slot.flipping ? slot.prev : slot.value"></span></span>
                            {{-- Animating flaps, only present mid-flip. --}}
                            <template x-if="slot.flipping">
                                <span class="ui-flipcard__flap ui-flipcard__flap--top"><span class="ui-flipcard__digit" x-text="slot.prev"></span></span>
                            </template>
                            <template x-if="slot.flipping">
                                <span class="ui-flipcard__flap ui-flipcard__flap--bottom"><span class="ui-flipcard__digit" x-text="slot.value"></span></span>
                            </template>
                        </span>
                    </template>
                </div>
                @if ($showLabels)
                    <span class="{{ $labelClass }} font-medium uppercase tracking-wide text-muted-foreground" data-slot="flip-clock-label" x-text="unit.label"></span>
                @endif
            </div>
        </div>
    </template>
</div>
resources/js/ui/flip-clock.js JS
/**
 * Flip clock behavior (spec §19 behavior layer).
 *
 * Drives a split-flap clock or countdown. Each digit is a slot whose target
 * value updates every second; when a digit changes it flips (CSS keyframe in
 * ui.css) and swaps its displayed glyph at the edge-on frame. The slot array
 * is rebuilt when the structure changes (e.g. the days unit appears/grows).
 *
 * Self-registers on `alpine:init` so import order does not matter.
 */
document.addEventListener('alpine:init', () => {
    window.Alpine.data('uiFlipClock', (config = {}) => ({
        countdown: config.countdown ?? false,
        target: config.targetDate ? Date.parse(config.targetDate) : null,
        showDays: config.showDays || 'auto',
        // Localized unit labels ({ days, hours, minutes, seconds }); an empty
        // object renders the bare board.
        labels: config.labels || {},
        // Units, each holding its digit slots; `slots` is the same digits flat
        // (with separators) for the tick diff.
        units: [],
        slots: [],
        timer: null,

        init() {
            this.build();
            this.timer = setInterval(() => this.tick(), 1000);
        },

        destroy() {
            if (this.timer) clearInterval(this.timer);
        },

        pad(n, len = 2) {
            return String(Math.max(0, n)).padStart(len, '0');
        },

        // Return the time units as { key, value } with zero-padded values
        // (most-significant first).
        parts() {
            if (this.countdown && this.target !== null) {
                let diff = Math.max(0, Math.floor((this.target - Date.now()) / 1000));
                const d = Math.floor(diff / 86400); diff -= d * 86400;
                const h = Math.floor(diff / 3600); diff -= h * 3600;
                const m = Math.floor(diff / 60); const s = diff - m * 60;
                const showD = this.showDays === 'always' || (this.showDays !== 'never' && d > 0);
                const units = [];
                if (showD) units.push({ key: 'days', value: this.pad(d, d > 99 ? 3 : 2) });
                units.push({ key: 'hours', value: this.pad(h) }, { key: 'minutes', value: this.pad(m) }, { key: 'seconds', value: this.pad(s) });
                return units;
            }
            const now = new Date();
            return [
                { key: 'hours', value: this.pad(now.getHours()) },
                { key: 'minutes', value: this.pad(now.getMinutes()) },
                { key: 'seconds', value: this.pad(now.getSeconds()) },
            ];
        },

        build() {
            const units = [];
            const slots = [];
            this.parts().forEach((unit, ui) => {
                if (ui > 0) slots.push({ type: 'sep' });
                const digits = [];
                for (const ch of unit.value) {
                    const slot = { type: 'digit', value: ch, prev: ch, flipping: false };
                    digits.push(slot);
                    slots.push(slot);
                }
                units.push({ key: unit.key, label: this.labels[unit.key] ?? '', digits });
            });
            this.units = units;
            this.slots = slots;
        },

        tick() {
            const flat = [];
            this.parts().forEach((unit, ui) => {
                if (ui > 0) flat.push(null);
                for (const ch of unit.value) flat.push(ch);
            });

            // Structure changed (days unit toggled / grew a digit) → rebuild.
            if (flat.length !== this.slots.length) {
                this.build();
                return;
            }

            flat.forEach((ch, i) => {
                const slot = this.slots[i];
                if (slot.type !== 'digit' || ch === slot.value) return;
                // prev holds the old digit for the folding flaps; value is the new
                // one (shown on the static top + descending bottom flap). The CSS
                // does the visual swap, so no mid-flight mutation is needed.
                slot.prev = slot.value;
                slot.value = ch;
                slot.flipping = true;
                setTimeout(() => { slot.flipping = false; slot.prev = ch; }, 600);
            });
        },
    }));
});

Ownership & lifecycle

Owner, release state, review evidence and adoption for this item.
Owner
Platform UI (@JoshJML)
Current version
1.2.1
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