Skip to content
Brok UI

Loading…

No results

Countdown

Open source

Countdown timers to a target moment — boxed unit cards with rolling digits, and a whimsical crawling-snail timer.

Version
v1.3.3
Stability
stable
License
MIT
Related
Snail Timer
1 of 2

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

Size
Tone
previews.components.countdown.default.blade.php Blade
<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

Sm Current
Card Current
Digit Current
Row Current
Gap Current
Label Current
Md Current
Lg Current
Xl Current

Tone options

Default Current
Inverse Current

Installation

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

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

  • blade resources/views/components/ui/countdown.blade.php
  • js resources/js/ui/countdown.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.

countdown.md
# 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

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

manifest knowledge + registry-derived coverage

Props

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

countdown countdown-unit

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

Progress and sequence

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
countdown countdown-unit
Theming hooks
countdown countdown-unit

Accessibility

WCAG 2.2 AA Keyboard focus-visible RTL-ready Localized labels Dark mode
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-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="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.

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