Timer / Countdown
stimeo--countdown
Counts down to a moment into day, hour, minute and second slots, and can pause and resume.
Counts the time left to a deadline and writes it into the day, hour, minute and second slots. It can count up from that moment instead. Announcing every second would be noise, so nothing is spoken until it finishes. The finish reaches screen readers and can show wording on screen as well. Pause it and it picks up where it stopped, without the time jumping while it was held. How often it ticks is up to you.
Keyboard
This component has no keyboard interactions of its own.
<%# 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; the status slot carries the completion text
visually and the shared stimeo--announcer your app seats once, in its layout,
reads it out, so it is said exactly once (reaching zero is the transition worth
reading; the ticking numbers in between are state the reader can query).
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-stimeo--countdown-announce-text-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>
<%# Visual only: the announcer says this once, so a live region here would repeat it. %>
<span class="countdown__status" 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-700);
}
.countdown__status {
align-self: center;
font-size: 0.95rem;
color: var(--accent-700);
}
.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 slot that shows 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 |
announceText
|
Wording read out on completion; empty announces nothing. | data-stimeo--countdown-announce-text-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. |