Timer / Countdown
stimeo--countdown
Counts down to a deadline into day/hour/minute/second slots, with pause/resume.
The stimeo--countdown controller follows the role="timer" live-region practice. It computes the time remaining to deadline (or elapsed since it, in direction="up"), formats it into the day/hour/minute/second slots, and ticks on interval. aria-live="off" avoids announcing every second; only completion is surfaced — via the optional status live region when a completeLabel is set, or the complete event. Pause/resume shifts an internal time anchor so the displayed amount is preserved across a pause. It dispatches stimeo--countdown:tick / :complete, and the interval is torn down on disconnect (Turbo included). Behavior only — slot styling is owned by this Playground.
<%# Markup for the countdown (timer) demo.
Fills the days/hours/minutes/seconds slots with the time remaining until the
deadline, updating every interval (default 1000ms). role="timer" + aria-live="off"
avoids per-second announcements; completion is announced by writing text into
status (aria-live="polite"). The deadline is computed in ERB to always be in the future.
The pause/resume/reset buttons sit outside the timer (a role="timer" live region
should not wrap unrelated controls), so they drive it through the countdown:* events
the controller listens for (see demo.js) instead of a Stimulus data-action, which
only binds within the controller's own element.
This demo behaves as a *duration* timer (a fixed length counted from "now"): the
deadline is now + the duration below, and Reset re-anchors it to now + the same
duration so the clock returns to the full time. The library only counts down to
whatever deadline it is given; demo.js owns that duration→deadline policy and exposes
the length via data-countdown-duration-ms. %>
<% countdown_duration = 1.hour %>
<div class="countdown-demo" data-countdown-duration-ms="<%= countdown_duration.to_i * 1000 %>">
<div
class="countdown"
data-controller="stimeo--countdown"
role="timer"
aria-live="off"
data-stimeo--countdown-deadline-value="<%= countdown_duration.from_now.iso8601 %>"
data-stimeo--countdown-complete-label-value="<%= t('components.countdown.demo.complete') %>"
data-action="
countdown:pause->stimeo--countdown#pause
countdown:resume->stimeo--countdown#resume
countdown:reset->stimeo--countdown#reset">
<span class="countdown__unit">
<span class="countdown__value" data-stimeo--countdown-target="days">0</span>
<span class="countdown__label"><%= t("components.countdown.demo.days") %></span>
</span>
<span class="countdown__unit">
<span class="countdown__value" data-stimeo--countdown-target="hours">00</span>
<span class="countdown__label"><%= t("components.countdown.demo.hours") %></span>
</span>
<span class="countdown__unit">
<span class="countdown__value" data-stimeo--countdown-target="minutes">00</span>
<span class="countdown__label"><%= t("components.countdown.demo.minutes") %></span>
</span>
<span class="countdown__unit">
<span class="countdown__value" data-stimeo--countdown-target="seconds">00</span>
<span class="countdown__label"><%= t("components.countdown.demo.seconds") %></span>
</span>
<span class="countdown__status" role="status" aria-live="polite"
data-stimeo--countdown-target="status"></span>
</div>
<div class="countdown-demo__controls">
<button class="demo-trigger" type="button" data-countdown-pause>
<%= t("components.countdown.demo.pause") %>
</button>
<button class="demo-trigger" type="button" data-countdown-resume>
<%= t("components.countdown.demo.resume") %>
</button>
<button class="demo-trigger" type="button" data-countdown-reset>
<%= t("components.countdown.demo.reset") %>
</button>
</div>
</div>
/*
* Presentation-only styles for the countdown demo.
* This CSS owns the slot styling; the library only updates each slot's text and
* reflects data-state (running / paused / complete).
*/
.countdown-demo {
display: flex;
flex-direction: column;
gap: 1rem;
max-width: 32rem;
}
.countdown {
display: flex;
align-items: flex-end;
gap: 0.75rem;
}
.countdown__unit {
display: inline-flex;
flex-direction: column;
align-items: center;
min-width: 3.25rem;
padding: 0.5rem;
border: 1px solid var(--border);
border-radius: 0.375rem;
background: var(--surface-subtle);
}
.countdown__value {
font-size: 1.6rem;
font-variant-numeric: tabular-nums;
font-weight: 600;
color: var(--fg);
}
.countdown__label {
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--color-text-muted);
}
/* Visualize state: dimmed while paused, accent color when complete. */
.countdown[data-state="paused"] .countdown__value {
opacity: 0.5;
}
.countdown[data-state="complete"] .countdown__value {
color: var(--accent);
}
.countdown__status {
align-self: center;
font-size: 0.95rem;
color: var(--accent);
}
.countdown-demo__controls {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
// countdown external-control demo (consumer-side JS).
//
// The core controller (stimeo--countdown) ticks the timer and exposes pause /
// resume / reset, but owns no buttons. The role="timer" element is a live region
// that should not wrap unrelated controls, so the buttons live beside it — where a
// Stimulus data-action would never bind (actions only wire up within the
// controller's own element). The controller already listens for the countdown:*
// events (see the markup's data-action), so here each button dispatches the
// matching event on the timer element.
//
// Pause/Resume map straight to the controller. Reset is a duration-timer policy that
// lives here, not in the library: the controller counts down to a fixed deadline, so
// "reset to the full time" means re-anchoring the deadline to now + the configured
// duration before asking the controller to restart from it. Without this, reset would
// only re-sync to the original (already-elapsed) deadline and the clock would not
// return to the full time.
document.querySelectorAll(".countdown-demo").forEach((root) => {
const timer = root.querySelector('.countdown[data-controller~="stimeo--countdown"]');
const controls = root.querySelector(".countdown-demo__controls");
if (!timer || !controls) return;
const durationMs = Number(root.getAttribute("data-countdown-duration-ms")) || 0;
const wire = (selector, eventType) => {
controls.querySelector(selector)?.addEventListener("click", () => {
timer.dispatchEvent(new CustomEvent(eventType));
});
};
wire("[data-countdown-pause]", "countdown:pause");
wire("[data-countdown-resume]", "countdown:resume");
controls.querySelector("[data-countdown-reset]")?.addEventListener("click", () => {
if (durationMs > 0) {
const freshDeadline = new Date(Date.now() + durationMs).toISOString();
timer.setAttribute("data-stimeo--countdown-deadline-value", freshDeadline);
}
timer.dispatchEvent(new CustomEvent("countdown:reset"));
});
});
These demo styles use shared design tokens (light + dark). Copy the shared styles too, then toggle data-theme on your root element for dark mode.
The data-* attributes you add to your own HTML to wire this component. Put the data-controller below on a root element, then place its targets / values / actions inside that element.
On the root element
data-controller="stimeo--countdown"
Targets
| Name | Description | Attribute |
|---|---|---|
days
|
Slot element whose text shows the remaining/elapsed whole days. | data-stimeo--countdown-target="days" |
hours
|
Slot element whose text shows the hours, zero-padded to two digits. | data-stimeo--countdown-target="hours" |
minutes
|
Slot element whose text shows the minutes, zero-padded to two digits. | data-stimeo--countdown-target="minutes" |
seconds
|
Slot element whose text shows the seconds, zero-padded to two digits. | data-stimeo--countdown-target="seconds" |
status
|
Optional live region that receives the completion label when the timer finishes. | data-stimeo--countdown-target="status" |
Values
| Name | Description | Attribute |
|---|---|---|
deadline
|
Target timestamp (parseable date string) the timer counts toward or from; empty by default. | data-stimeo--countdown-deadline-value |
interval
|
Tick interval in milliseconds (default 1000). | data-stimeo--countdown-interval-value |
direction
|
Counting direction: down (remaining) or up (elapsed); default down. |
data-stimeo--countdown-direction-value |
autostart
|
Whether to start ticking automatically on connect (default true). | data-stimeo--countdown-autostart-value |
completeLabel
|
Text written into the status region on completion; empty disables it. | data-stimeo--countdown-complete-label-value |
Actions
| Name | Description | Action |
|---|---|---|
pause
|
Pauses ticking, preserving the currently displayed amount. | stimeo--countdown#pause |
reset
|
Re-syncs to the deadline and clears the pause offset, preserving the current run state. | stimeo--countdown#reset |
resume
|
Resumes from a pause, continuing from the preserved amount. | stimeo--countdown#resume |
start
|
Starts (or restarts after a pause) ticking toward the deadline. | stimeo--countdown#start |
Events
| Name | Description | Event |
|---|---|---|
complete
|
Fires when a countdown reaches zero. | stimeo--countdown:complete |
tick
|
Fires each tick with detail.remaining (displayed amount) and detail.direction. | stimeo--countdown:tick |
State hooks
The library only manages these ARIA/data attributes and custom properties. Your CSS reads them to render the look — selectors like [aria-selected], [aria-expanded], or var(--stimeo--…) hook into this state.
| Hook | Target | Meaning |
|---|---|---|
data-state |
Root element | "running" / "paused" / "complete". |
text content |
Day / Hour / Minute / Second slots | The remaining time in each unit. |