Countdown
Countdown timers to a target moment — boxed unit cards with rolling digits, and a whimsical crawling-snail timer.
Snail Timer — A whimsical countdown: a cute snail slowly crawls along a horizontal track from start to finish over the countdown duration while a "{n} seconds remaining" label ticks down each second. A Start/Stop toggle controls the run (optional autostart); at zero it shows a finished state with the snail parked at the finish flag. Token-colored, reduced-motion safe, RTL-aware.
Preview
Steeping your tea
Finished!
<div class="w-full flex h-56 items-center justify-center p-6">
<x-ui.card class="w-full max-w-sm p-6">
<x-ui.snail-timer :duration="30" :autostart="true" label="Steeping your tea" />
</x-ui.card>
</div>
Installation
php artisan ui:add snail-timer
Note
This component ships an Alpine behavior module at
resources/js/ui/snail-timer.js. Import it once from your bundle so it registers on alpine:init:
import './snail-timer.js';
Registry contract
php artisan ui:add snail-timer
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/snail-timer.blade.php -
resources/js/ui/snail-timer.js
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: Snail Timer (`snail-timer`)
A whimsical countdown: a cute snail slowly crawls along a horizontal track from start to finish over the countdown duration while a "{n} seconds remaining" label ticks down each second. A Start/Stop toggle controls the run (optional autostart); at zero it shows a finished state with the snail parked at the finish flag. Token-colored, reduced-motion safe, RTL-aware.
Brok UI is a Laravel Blade component registry. Installed components are plain Blade files the app owns.
## Install
```bash
php artisan ui:add snail-timer
```
## Usage
```blade
<div class="w-full flex h-56 items-center justify-center p-6">
<x-ui.card class="w-full max-w-sm p-6">
<x-ui.snail-timer :duration="30" :autostart="true" label="Steeping your tea" />
</x-ui.card>
</div>
```
## Props
- `duration` (int, default `60`) — Countdown length in seconds.
- `autostart` (bool, default `false`) — Starts the timer automatically on mount when true.
- `label` (mixed|null, default `null`) — Optional heading text shown above the track.
## Use when
- Use to show real progress, ordered stages, or process status when visibility helps completion.
- Adding a lighthearted, branded countdown for a short wait (loading, a timed offer, a break timer) where a plain numeric countdown feels flat.
- Wanting a Start/Stop toggle with optional autostart built in.
## Avoid when
- Do not add progress UI to tiny tasks or to flows whose steps are not meaningfully sequential.
- Needing a precise, serious countdown display without a whimsical animation; use a plain numeric countdown instead.
## Anti-patterns
- Decorative or inaccurate progress
- Steppers for nonsequential exploration
## Rules
- Use the `<brok:snail-timer>` tag (or `<x-ui.snail-timer>`) 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/snail-timer
- Registry JSON (files, props, contract): https://brokui.dev/r/open/snail-timer.json
Working in Claude Code, Cursor or Codex? Give the agent the whole registry through the MCP server or the Brok UI skill.
API
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| duration | int | 60 | Countdown length in seconds. |
| autostart | bool | false | Starts the timer automatically on mount when true. |
| label | mixed | null | null | Optional heading text shown above the track. |
Slots
default— Not used by this component; present for attribute-bag compatibility.
Data slots
Stable hooks for CSS overrides and browser tests.
Behavior
- A Start/Stop button, or autostart, drives a snail crawling along the track over duration seconds via a single CSS custom property updated every animation frame; at zero it shows a finished state with the snail parked at the finish flag.
- 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.
- Adding a lighthearted, branded countdown for a short wait (loading, a timed offer, a break timer) where a plain numeric countdown feels flat.
- Wanting a Start/Stop toggle with optional autostart built in.
Avoid when
- Do not add progress UI to tiny tasks or to flows whose steps are not meaningfully sequential.
- Needing a precise, serious countdown display without a whimsical animation; use a plain numeric countdown instead.
Use instead
- Simple completion text for tiny tasks
Anti-patterns
- Decorative or inaccurate progress
- Steppers for nonsequential exploration
- Anatomy
- Theming hooks
Accessibility
- Keyboard
- native
- Focus
native
- The remaining-time text lives in an aria-live="polite" region, the toggle is a real button with a label that reflects its current state, and the decorative snail/track SVGs are aria-hidden.
- Reduced motion drops the per-frame crawl: the snail jumps to its position once per second and only the numeric text updates.
- 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="snail-timer-{{ $record->id }}">
<div class="w-full flex h-56 items-center justify-center p-6">
<x-ui.card class="w-full max-w-sm p-6">
<x-ui.snail-timer :duration="30" :autostart="true" label="Steeping your tea" />
</x-ui.card>
</div>
</div>
Validation
Validation support: native. Keep the error message connected with aria-describedby.
<form wire:submit="save" class="space-y-2">
<brok:snail-timer
wire:model="value"
:aria-invalid="$errors->has('value') ? 'true' : 'false'"
aria-describedby="value-error"
/>
@error('value')
<p id="value-error" role="alert">{{ $message }}</p>
@enderror
<brok:button type="submit" wire:loading.attr="disabled">
<span wire:loading.remove>Save</span>
<span wire:loading>Saving…</span>
</brok:button>
</form>
Source
The exact, editable files ui:add writes
into your app. Previews render this same code; there are no preview-only components.
@props([
'duration' => 60,
'autostart' => false,
'label' => null,
])
{{--
Snail Timer — a whimsical countdown. A cute snail slowly crawls along a
horizontal track from inline-start to inline-end over `duration` seconds while
a "{n} seconds remaining" label ticks down each second. A Start/Stop button
toggles the run (the timer can also auto-start). At zero it shows a finished
state with the snail parked at the finish flag.
The crawl is driven by a single CSS custom property (`--snail-progress`, a
0→1 fraction) that a fixed class reads via `translateX(calc(...))` — never a
spliced arbitrary class, which the Tailwind JIT can't see. JS updates that var
every animation frame for a smooth glide.
A11y: the remaining-time text lives in an aria-live="polite" region; the
toggle is a real <button> with an accessible label that reflects state; the
decorative snail/track SVGs are aria-hidden. Logical properties + a flipped
snail under dir="rtl" keep the crawl mirroring correctly.
Reduced motion: no rAF crawl — the snail jumps to its position once per
second and only the numeric text updates.
--}}
@php
$duration = max(1, (int) $duration);
$resolvedLabel = $label ? __($label) : null;
$config = [
'duration' => $duration,
'autostart' => (bool) $autostart,
// Localized label templates resolved in PHP and handed to Alpine. `:count`
// is substituted with the live remaining seconds.
'labels' => [
'one' => __(':count second remaining'),
'many' => __(':count seconds remaining'),
'start' => __('Start timer'),
'stop' => __('Stop timer'),
'restart' => __('Restart'),
],
];
@endphp
<div
x-data="uiSnailTimer(@js($config))"
data-slot="snail-timer"
style="--snail-progress: 0"
{{ $attributes->merge(['class' => 'flex w-full max-w-sm flex-col gap-4']) }}
>
@if ($resolvedLabel)
<p class="text-sm font-medium text-foreground">{{ $resolvedLabel }}</p>
@endif
{{-- The track. The snail rides along it; a small finish flag marks the end.
The snail's offset is `--snail-progress` (0→1) read by a fixed
translateX, so position is data-driven without a spliced class. RTL is
handled by negating the translate (logical crawl start→end). --}}
<div
class="relative h-16 w-full overflow-hidden rounded-full border border-border bg-muted"
style="container-type: inline-size"
data-slot="snail-timer-track"
>
{{-- Dotted baseline, and the slime trail that fills in behind the snail
(its width reads --snail-progress, the same variable as the crawl). --}}
<div
class="pointer-events-none absolute inset-x-4 top-1/2 h-px -translate-y-1/2 border-t border-dashed border-border"
aria-hidden="true"
></div>
<div
data-slot="snail-timer-trail"
class="pointer-events-none absolute start-4 top-1/2 h-0.5 -translate-y-1/2 rounded-full bg-primary/30"
style="width: max(0px, calc(var(--snail-progress) * (100% - 3.5rem) - 0.5rem))"
aria-hidden="true"
></div>
{{-- Finish flag at the inline-end. --}}
<div
class="pointer-events-none absolute inset-y-0 end-3 flex items-center text-muted-foreground"
aria-hidden="true"
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-5">
<path d="M4 21V4" />
<path d="M4 4h11l-1.5 3L15 10H4" fill="currentColor" stroke="none" />
</svg>
</div>
{{-- The crawling snail. translateX reads --snail-progress; rtl:-scale-x-100
flips the snail to face its travel direction under RTL. The rAF/CSS
transition lives in JS so reduced motion can disable it. --}}
<div
class="absolute inset-y-0 start-1 flex items-center text-primary will-change-transform"
:style="snailStyle"
data-slot="snail-timer-crawler"
aria-hidden="true"
>
{{-- The snail faces its direction of travel: shell at the back (start),
head and antennae at the front (end). --}}
<svg viewBox="0 0 64 48" fill="none" class="size-12 rtl:-scale-x-100" role="img" aria-hidden="true">
{{-- Foot and body, rising into the head at the front --}}
<path d="M6 40h40c5 0 8-3 8-8v-8" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" />
{{-- Shell at the back --}}
<circle cx="22" cy="27" r="13" fill="none" stroke="currentColor" stroke-width="3" />
<path d="M22 27a6 6 0 1 1 6 6" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" />
{{-- Antennae with eyes --}}
<path d="M52 24l3-8M56 24l2-8" stroke="currentColor" stroke-width="3" stroke-linecap="round" />
<circle cx="55" cy="15" r="1.75" fill="currentColor" />
<circle cx="58.5" cy="15" r="1.75" fill="currentColor" />
</svg>
</div>
</div>
<div class="flex items-center justify-between gap-3">
{{-- Live remaining-time / finished text. --}}
<p
class="text-sm tabular-nums text-muted-foreground"
aria-live="polite"
data-slot="snail-timer-status"
>
<span x-show="!finished" x-text="remainingLabel"></span>
<span x-show="finished" x-cloak class="font-medium text-foreground">{{ __('Finished!') }}</span>
</p>
<x-ui.button
type="button"
size="sm"
variant="outline"
@click="toggle()"
::aria-label="running ? labels.stop : labels.start"
>
<span x-show="!running" x-text="finished ? labels.restart : labels.start">{{ __('Start') }}</span>
<span x-show="running" x-cloak>{{ __('Stop') }}</span>
</x-ui.button>
</div>
</div>
/**
* Snail Timer behavior — a whimsical wall-clock countdown.
*
* A snail crawls a horizontal track from start to finish over `duration`
* seconds. Two things update over time:
* 1. `--snail-progress` (0 → 1) drives the snail's translateX via a fixed
* class in the Blade — a continuous glide animated with requestAnimationFrame.
* 2. `remaining` (whole seconds left) drives the aria-live label, recomputed
* from wall-clock deltas so it stays accurate even if frames are dropped.
*
* Reduced motion: no rAF crawl. The progress var is snapped once per second (so
* the snail hops rather than glides) and only the numeric text animates. The
* snail's CSS transition is likewise disabled.
*
* Cleanup: the rAF handle and the per-second interval are both torn down in
* destroy() so unmounting mid-run never leaks a loop or a timer.
*
* Self-registers on `alpine:init` so import order does not matter.
*/
document.addEventListener('alpine:init', () => {
window.Alpine.data('uiSnailTimer', (config = {}) => ({
duration: Math.max(1, Number(config.duration) || 60),
autostart: Boolean(config.autostart),
// Localized ":count second(s) remaining" templates from the Blade.
labels: config.labels || {
one: ':count second remaining',
many: ':count seconds remaining',
},
reduceMotion:
typeof window.matchMedia === 'function' &&
window.matchMedia('(prefers-reduced-motion: reduce)').matches,
running: false,
finished: false,
// Whole seconds remaining (for the label) and 0→1 crawl progress.
remaining: 0,
progress: 0,
// Wall-clock anchor: the timestamp the deadline falls at.
deadline: 0,
raf: null,
ticker: null,
init() {
this.remaining = this.duration;
this.applyProgress();
if (this.autostart) {
// Defer one frame so x-cloak/initial paint settle first.
requestAnimationFrame(() => this.start());
}
},
destroy() {
this.stopLoops();
},
// --- Controls --------------------------------------------------------
toggle() {
if (this.running) {
this.stop();
} else {
this.start();
}
},
start() {
if (this.running) return;
// Restart from the top once finished.
if (this.finished || this.remaining <= 0) {
this.finished = false;
this.remaining = this.duration;
this.progress = 0;
this.applyProgress();
}
this.running = true;
// Anchor the deadline to the remaining time so Stop/Start resumes.
this.deadline = Date.now() + this.remaining * 1000;
// Whole-second label updates (kept on a clean 1s cadence).
this.ticker = setInterval(() => this.tickSecond(), 250);
if (this.reduceMotion) {
// No glide — the second tick will hop the snail.
this.tickSecond();
} else {
this.loop();
}
},
stop() {
this.running = false;
this.stopLoops();
},
stopLoops() {
if (this.raf !== null) {
cancelAnimationFrame(this.raf);
this.raf = null;
}
if (this.ticker !== null) {
clearInterval(this.ticker);
this.ticker = null;
}
},
// --- Loops -----------------------------------------------------------
// Smooth crawl: recompute progress from the wall clock every frame.
loop() {
this.raf = requestAnimationFrame(() => {
const left = Math.max(0, this.deadline - Date.now()) / 1000;
this.progress = Math.min(1, 1 - left / this.duration);
this.applyProgress();
if (left <= 0) {
this.finish();
return;
}
if (this.running) this.loop();
});
},
// Whole-second cadence for the label (and the hop under reduced motion).
tickSecond() {
const left = Math.max(0, this.deadline - Date.now()) / 1000;
this.remaining = Math.ceil(left);
if (this.reduceMotion) {
this.progress = Math.min(1, 1 - left / this.duration);
this.applyProgress();
}
if (left <= 0) this.finish();
},
finish() {
this.running = false;
this.finished = true;
this.remaining = 0;
this.progress = 1;
this.applyProgress();
this.stopLoops();
},
// --- Bindings --------------------------------------------------------
// Push the 0→1 progress into the CSS var the Blade's fixed class reads.
applyProgress() {
if (this.$el) this.$el.style.setProperty('--snail-progress', String(this.progress));
},
// The crawler offset. translateX spans the track inset (start→end). The
// transition glides between rAF frames except under reduced motion.
get snailStyle() {
const transition = this.reduceMotion
? 'none'
: 'transform 220ms linear';
return (
'transform: translateX(calc(var(--snail-progress) * ' +
'(100cqi - 3.5rem)));' +
'transition: ' +
transition +
';'
);
},
get remainingLabel() {
const n = Math.max(0, this.remaining);
const template = n === 1 ? this.labels.one : this.labels.many;
return template.replace(':count', n);
},
}));
});
Ownership & lifecycle
Owner, release state, review evidence and adoption for this item.
- Owner
- Platform UI (@JoshJML)
- Current version
-
1.1.4 - 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