Flip Board
Mechanical split-flap displays — a departure-board text panel and a clock/countdown of flipping digits.
Flip Clock — A split-flap clock or countdown with size and variant options and an auto/always/never days unit.
Preview
<x-ui.flip-clock />
Size options
Variant options
Installation
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:
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.
-
resources/views/components/ui/flip-clock.blade.php -
resources/js/ui/flip-clock.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: 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
<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
Props
| 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.
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
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
- Theming hooks
Accessibility
- 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-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="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.
@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>
/**
* 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