Countdown
Countdown timers to a target moment — boxed unit cards with rolling digits, and a whimsical crawling-snail timer.
Countdown — An animated countdown timer to a target date. Four boxed unit cards (Days, Hours, Minutes, Seconds) show zero-padded numbers with localized labels; changing digits roll with a smooth vertical odometer slide. Reduced-motion safe, RTL aware (digits stay LTR), and token-themed.
Preview
<x-ui.countdown
:target-date="\Illuminate\Support\Carbon::create(2027, 1, 1, 0, 0, 0)->toIso8601String()"
show-days="always"
variant="outline"
size="lg"
/>
Size options
Tone options
Installation
php artisan ui:add countdown
Note
This component ships an Alpine behavior module at
resources/js/ui/countdown.js. Import it once from your bundle so it registers on alpine:init:
import './countdown.js';
Registry contract
php artisan ui:add countdown
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/countdown.blade.php -
resources/js/ui/countdown.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: Countdown (`countdown`)
An animated countdown timer to a target date. Four boxed unit cards (Days, Hours, Minutes, Seconds) show zero-padded numbers with localized labels; changing digits roll with a smooth vertical odometer slide. Reduced-motion safe, RTL aware (digits stay LTR), and token-themed.
Brok UI is a Laravel Blade component registry. Installed components are plain Blade files the app owns.
## Install
```bash
php artisan ui:add countdown
```
## Usage
```blade
<x-ui.countdown
:target-date="\Illuminate\Support\Carbon::create(2027, 1, 1, 0, 0, 0)->toIso8601String()"
show-days="always"
variant="outline"
size="lg"
/>
```
## Props
- `targetDate` (string|DateTimeInterface|null, default `null`) — The moment being counted down to. Parsed by Carbon and handed to Alpine as ISO-8601.
- `showDays` (auto|always|never, default `auto`) — Whether the days card is rendered. `auto` drops it once fewer than 24 hours remain.
- `size` (sm|md|lg|xl, default `md`) — Card geometry and digit scale.
- `variant` (default|secondary|outline|muted|plain, default `default`) — Surface of each unit card. `plain` drops the box for bare figures in the current text colour.
- `tone` (default|inverse, default `default`) — `inverse` restates every variant for a timer sitting on a primary surface — the cards and, crucially, the unit labels read against primary instead of the page background.
- `labels` (array, default `[]`) — Overrides for the localized unit labels, keyed days|hours|minutes|seconds.
## Use when
- Use to show real progress, ordered stages, or process status when visibility helps completion.
- Counting down to a real deadline a visitor can act on — a sale end, a launch, a registration close.
- The timer sits on a primary-tinted bar or hero: pass tone="inverse" so the unit labels stay legible.
## Avoid when
- Do not add progress UI to tiny tasks or to flows whose steps are not meaningfully sequential.
- There is no real deadline; a fake urgency clock is a dark pattern.
- You want elapsed time since an event — that is a relative timestamp, not this.
- A mechanical flip-board look is wanted — use <x-ui.flip-clock>.
## Anti-patterns
- Decorative or inaccurate progress
- Steppers for nonsequential exploration
## Rules
- Use the `<brok:countdown>` tag (or `<x-ui.countdown>`) 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/countdown
- Registry JSON (files, props, contract): https://brokui.dev/r/open/countdown.json
Working in Claude Code, Cursor or Codex? Give the agent the whole registry through the MCP server or the Brok UI skill.
Examples
<div class="w-full rounded-lg bg-primary px-6 py-8 text-primary-foreground">
<div class="flex flex-col items-center gap-4 text-center">
<p class="text-sm font-semibold">{{ __('Summer sale ends in') }}</p>
<x-ui.countdown
:target-date="\Illuminate\Support\Carbon::create(2027, 1, 1, 0, 0, 0)->toIso8601String()"
show-days="always"
variant="outline"
tone="inverse"
/>
</div>
</div>
API
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| targetDate | string | DateTimeInterface | null | null | The moment being counted down to. Parsed by Carbon and handed to Alpine as ISO-8601. |
| showDays | auto | always | never | auto | Whether the days card is rendered. `auto` drops it once fewer than 24 hours remain. |
| size | sm | md | lg | xl | md | Card geometry and digit scale. |
| variant | default | secondary | outline | muted | plain | default | Surface of each unit card. `plain` drops the box for bare figures in the current text colour. |
| tone | default | inverse | default | `inverse` restates every variant for a timer sitting on a primary surface — the cards and, crucially, the unit labels read against primary instead of the page background. |
| labels | array | [] | Overrides for the localized unit labels, keyed days|hours|minutes|seconds. |
Slots
Default Blade slot only.
Data slots
Stable hooks for CSS overrides and browser tests.
Behavior
- Installs a lazy-loaded JavaScript behavior module.
- 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
Communicate genuine progress or ordered stages.
Use when
- Use to show real progress, ordered stages, or process status when visibility helps completion.
- Counting down to a real deadline a visitor can act on — a sale end, a launch, a registration close.
- The timer sits on a primary-tinted bar or hero: pass tone="inverse" so the unit labels stay legible.
Avoid when
- Do not add progress UI to tiny tasks or to flows whose steps are not meaningfully sequential.
- There is no real deadline; a fake urgency clock is a dark pattern.
- You want elapsed time since an event — that is a relative timestamp, not this.
- A mechanical flip-board look is wanted — use <x-ui.flip-clock>.
Use instead
- Simple completion text for tiny tasks
Anti-patterns
- Decorative or inaccurate progress
- Steppers for nonsequential exploration
- Anatomy
- Theming hooks
Accessibility
- Keyboard
- managed
- Focus
none
- role="timer" with aria-live="off": the ticking value is not announced every second. Give the timer an aria-label that names the event, and show the target date as text nearby.
- Digit columns are dir="ltr" so numbers keep their order under RTL; unit labels are localized.
- With prefers-reduced-motion the digits change without the odometer transition.
- 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="countdown-{{ $record->id }}">
<x-ui.countdown
:target-date="\Illuminate\Support\Carbon::create(2027, 1, 1, 0, 0, 0)->toIso8601String()"
show-days="always"
variant="outline"
size="lg"
/>
</div>
Source
The exact, editable files ui:add writes
into your app. Previews render this same code; there are no preview-only components.
@props([
'targetDate' => null,
'showDays' => 'auto',
'size' => 'md',
'variant' => 'default',
'tone' => 'default',
'labels' => [],
])
@php
$styles = require base_path(config('ui.component_path', 'resources/views/components/ui').'/_styles.php');
$variant = $styles['normalizeVariant']($variant);
// Size scale: card box geometry + the per-digit row height. The row height
// (`row`) is mirrored into the JS config so the odometer strip translates by
// exactly one digit. Everything here is a literal class string so the JIT
// scanner can see it — never splice a variable into an arbitrary class.
$sizes = [
'sm' => ['card' => 'min-w-12 px-2 py-2 rounded-md', 'digit' => 'text-2xl', 'row' => 28, 'gap' => 'gap-2', 'label' => 'text-[0.625rem]'],
'md' => ['card' => 'min-w-16 px-3 py-2 rounded-md', 'digit' => 'text-4xl', 'row' => 40, 'gap' => 'gap-2', 'label' => 'text-xs'],
'lg' => ['card' => 'min-w-20 px-4 py-3 rounded-md', 'digit' => 'text-6xl', 'row' => 60, 'gap' => 'gap-3', 'label' => 'text-sm'],
'xl' => ['card' => 'min-w-28 px-6 py-6 rounded-lg', 'digit' => 'text-8xl', 'row' => 80, 'gap' => 'gap-4', 'label' => 'text-base'],
];
// Boxed unit cards (visually distinct from flip-clock's mechanical board):
// a solid/outlined surface per unit, all tokenized.
//
// `tone="inverse"` is the same four variants restated for a timer sitting ON
// a primary surface (a sale bar, a dark hero): every value reads against
// primary, including the unit labels, which otherwise stay muted and vanish.
$tone = in_array($tone, ['default', 'inverse'], true) ? $tone : 'default';
$variants = $tone === 'inverse' ? [
'default' => 'bg-primary-foreground text-primary',
'secondary' => 'bg-primary-foreground/15 text-primary-foreground',
'outline' => 'border border-primary-foreground/30 bg-transparent text-primary-foreground',
'muted' => 'bg-primary-foreground/10 text-primary-foreground',
] : [
'default' => 'bg-primary text-primary-foreground',
'secondary' => 'bg-secondary text-secondary-foreground',
'outline' => 'border border-border bg-background text-foreground',
'muted' => 'bg-muted text-foreground',
];
$labelTone = $tone === 'inverse' ? 'text-primary-foreground/80' : 'text-muted-foreground';
$s = $sizes[$size] ?? $sizes['md'];
$variantClass = $variants[$variant] ?? $variants['default'];
// `plain` drops the box: bare figures in the current text colour for an
// editorial band that sets its own surface.
if ($variant === 'plain') {
$cardClass = 'flex flex-col items-center justify-center font-semibold tabular-nums '.$s['digit'];
$labelTone = 'opacity-70';
} else {
$cardClass = 'flex flex-col items-center justify-center font-semibold tabular-nums shadow-sm '
.$s['card'].' '.$s['digit'].' '.$variantClass;
}
// Localized unit labels; callers may override any of them via `labels`.
$unitLabels = array_merge([
'days' => __('Days'),
'hours' => __('Hours'),
'minutes' => __('Minutes'),
'seconds' => __('Seconds'),
], $labels);
$isoTarget = $targetDate
? \Illuminate\Support\Carbon::parse($targetDate)->toIso8601String()
: null;
$config = [
'targetDate' => $isoTarget,
'showDays' => $showDays,
// Pixel height of one digit row — drives the odometer translateY.
'rowHeight' => $s['row'],
// Localized labels resolved in PHP and handed to Alpine for rendering.
'labels' => $unitLabels,
];
@endphp
<div
x-data="uiCountdown(@js($config))"
data-slot="countdown"
data-variant="{{ $variant }}"
data-tone="{{ $tone }}"
role="timer"
aria-live="off"
{{ $attributes->merge(['class' => 'inline-flex items-end '.$s['gap']]) }}
>
{{-- Days card is conditionally rendered (hidden at 0 under showDays="auto"). --}}
<template x-for="unit in visibleUnits" :key="unit.key">
<div class="flex flex-col items-center gap-2">
{{-- The numeric box. Marked dir="ltr" so the two digit columns keep
their tens→ones order even when the page direction is RTL. --}}
<div class="{{ $cardClass }}" data-slot="countdown-unit" :data-unit="unit.key" dir="ltr">
<div class="flex">
{{-- One odometer column per digit. The strip stacks 0–9 and is
translated by `-value * rowHeight` so the target digit
lands in the viewport — transform only, no layout thrash. --}}
<template x-for="(digit, col) in unit.digits" :key="col">
<div class="overflow-hidden" :style="windowStyle">
<div
class="flex flex-col"
:style="stripStyle(digit)"
>
<template x-for="n in 10" :key="n">
<span class="flex items-center justify-center" :style="cellStyle" x-text="n - 1"></span>
</template>
</div>
</div>
</template>
</div>
</div>
<span class="{{ $s['label'] }} font-medium uppercase tracking-wide {{ $labelTone }}" x-text="unit.label"></span>
</div>
</template>
</div>
/**
* Countdown behavior — an odometer-style countdown timer.
*
* Unlike the mechanical split-flap `flip-clock`, this renders four boxed unit
* cards (Days / Hours / Minutes / Seconds). Each card holds one odometer
* "column" per digit: a vertical strip stacking 0–9 that is translated so the
* target digit sits in the viewport. When a digit changes the CSS transition on
* the strip slides it smoothly (a rolling odometer feel).
*
* The remaining time is recomputed from the ISO target every second; at zero it
* clamps every unit at 00 and stops ticking. Reduced-motion users get instant
* updates (the slide transition is disabled in CSS via the strip's transition,
* which we also short-circuit here by snapping the offset without animation).
*
* Self-registers on `alpine:init` so import order does not matter.
*/
document.addEventListener('alpine:init', () => {
window.Alpine.data('uiCountdown', (config = {}) => ({
target: config.targetDate ? Date.parse(config.targetDate) : null,
showDays: config.showDays || 'auto',
rowHeight: config.rowHeight || 40,
labels: config.labels || {},
// Honor the user's motion preference once at init.
reduceMotion:
typeof window.matchMedia === 'function' &&
window.matchMedia('(prefers-reduced-motion: reduce)').matches,
// Current remaining values, zero-padded strings keyed by unit.
values: { days: '00', hours: '00', minutes: '00', seconds: '00' },
showDaysCard: true,
timer: null,
init() {
this.compute();
this.timer = setInterval(() => this.compute(), 1000);
},
destroy() {
// Clear the per-second tick so unmounting never leaks a timer.
if (this.timer) {
clearInterval(this.timer);
this.timer = null;
}
},
pad(n, len = 2) {
return String(Math.max(0, n)).padStart(len, '0');
},
// Recompute remaining time from the ISO target. Clamps at zero and stops
// the interval once the deadline has passed.
compute() {
if (this.target === null) return;
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;
// Days can grow past two digits; pad to at least 2.
this.values = {
days: this.pad(d, d > 99 ? 3 : 2),
hours: this.pad(h),
minutes: this.pad(m),
seconds: this.pad(s),
};
this.showDaysCard =
this.showDays === 'always' ||
(this.showDays !== 'never' && d > 0);
// Reached zero → clamp (already 00 above) and stop ticking.
if (d === 0 && h === 0 && m === 0 && s === 0 && this.timer) {
clearInterval(this.timer);
this.timer = null;
}
},
// Units rendered by the template, in most-significant-first order. The
// days card is dropped entirely when hidden so it never reserves space.
get visibleUnits() {
const order = ['days', 'hours', 'minutes', 'seconds'];
return order
.filter((key) => key !== 'days' || this.showDaysCard)
.map((key) => ({
key,
label: this.labels[key] || key,
// Split the padded value into individual digit characters,
// one odometer column each.
digits: this.values[key].split(''),
}));
},
// --- Geometry helpers (transform/opacity only, no layout thrash) -----
// The visible window is exactly one digit-row tall.
get windowStyle() {
return `height:${this.rowHeight}px`;
},
// Each of the 10 stacked cells is one row tall.
get cellStyle() {
return `height:${this.rowHeight}px;line-height:${this.rowHeight}px`;
},
// Translate the strip so digit `n` lands in the window. Disable the
// transition entirely for reduced-motion users so it snaps instantly.
stripStyle(digit) {
const offset = -Number(digit) * this.rowHeight;
const transition = this.reduceMotion
? 'none'
: 'transform 600ms cubic-bezier(0.22, 1, 0.36, 1)';
return `transform:translateY(${offset}px);transition:${transition}`;
},
}));
});
Ownership & lifecycle
Owner, release state, review evidence and adoption for this item.
- Owner
- Platform UI (@JoshJML)
- Current version
-
1.3.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