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.1.4
Stability
stable
License
MIT
Related
Countdown

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!

previews.components.snail-timer.default.blade.php 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>

Installation

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

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

  • blade resources/views/components/ui/snail-timer.blade.php
  • js resources/js/ui/snail-timer.js
Registry dependencies
button
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.

snail-timer.md
# 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

manifest knowledge + registry-derived coverage

Props

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

snail-timer snail-timer-crawler snail-timer-status snail-timer-track snail-timer-trail

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

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.
  • 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
root snail-timer-track snail-timer-trail snail-timer-crawler snail-timer-status
Theming hooks
snail-timer snail-timer-crawler snail-timer-status snail-timer-track snail-timer-trail

Accessibility

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

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

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