Idle Detector
stimeo--idle
Fires idle after a span of no interaction (with an optional earlier prompt) and active on return.
The stimeo--idle controller watches document-level user activity (mousemove, keydown, scroll, …, plus returning to a visible tab) and, after timeout ms with none, marks the element data-idle and dispatches stimeo--idle:idle. An optional stimeo--idle:prompt fires promptBefore ms earlier so the app can warn before a hard timeout (supporting WCAG 2.2.1). Any activity re-arms the clock, and while idle or prompting the next interaction dispatches stimeo--idle:active and clears data-idle. Activity is observed on the document with capture + passive so non-bubbling events like scroll are seen anywhere. Behavior only — it renders no warning UI (pair with Dialog / Confirm) and never touches the server session; timers and listeners are torn down on disconnect (Turbo navigation included). Place one on the root element and use data-turbo-permanent to keep the count across visits.
Stop interacting with the page: a warning shows after 3s and the idle state after 6s. Move the mouse or press a key to return to active.
<%# Idle / session-timeout demo: the controller watches document-level activity and,
after a short demo timeout, fires prompt -> idle; interacting again fires active.
Real apps use a ~15-minute timeout; this demo shortens it (6s, warning at 3s) so the
transitions are visible. The library ships no UI, so demo.js mirrors the events into
the status badge below (whose labels are owned here for i18n). %>
<div
class="idle-demo"
data-controller="stimeo--idle"
data-stimeo--idle-timeout-value="6000"
data-stimeo--idle-prompt-before-value="3000">
<p class="idle-demo__hint"><%= t("components.idle.demo.hint") %></p>
<p
class="idle-demo__status"
role="status"
aria-live="polite"
data-idle-demo-status
data-label-active="<%= t("components.idle.demo.active") %>"
data-label-prompt="<%= t("components.idle.demo.prompt") %>"
data-label-idle="<%= t("components.idle.demo.idle") %>"></p>
</div>
/*
* Presentation-only styles for the idle demo. The library only fires events and toggles
* data-idle; this CSS lays out the hint + status badge and colors the badge by the
* demo-state mirror that demo.js writes (active / prompt / idle).
*/
.idle-demo {
display: flex;
flex-direction: column;
gap: 0.75rem;
max-width: 28rem;
}
.idle-demo__hint {
margin: 0;
color: var(--color-text-muted);
}
.idle-demo__status {
margin: 0;
padding: 0.5rem 0.75rem;
border: 1px solid var(--border);
border-radius: 0.375rem;
font-weight: 600;
text-align: center;
}
.idle-demo[data-demo-state="prompt"] .idle-demo__status {
border-color: var(--amber-500);
background: var(--amber-50);
color: var(--amber-500);
}
.idle-demo[data-demo-state="idle"] .idle-demo__status {
border-color: var(--danger-500);
background: var(--danger-50);
color: var(--color-accent);
}
// Idle demo (consumer-side JS).
//
// A ~15-minute production timeout can't be shown in a catalog, so the markup uses a
// short timeout / prompt window. The controller fires prompt -> idle as the page sits
// untouched and active when you interact again; this JS only mirrors those events into
// a visible status badge (the library ships no UI of its own). The badge copy is read
// from data-label-* so it stays owned by the i18n'd ERB.
document.querySelectorAll(".idle-demo").forEach((root) => {
const badge = root.querySelector("[data-idle-demo-status]");
if (!badge) return;
const labels = {
active: badge.dataset.labelActive,
prompt: badge.dataset.labelPrompt,
idle: badge.dataset.labelIdle,
};
const show = (state) => {
root.dataset.demoState = state;
badge.textContent = labels[state];
};
show("active");
root.addEventListener("stimeo--idle:prompt", () => show("prompt"));
root.addEventListener("stimeo--idle:idle", () => show("idle"));
root.addEventListener("stimeo--idle:active", () => show("active"));
});
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--idle"
Values
| Name | Description | Attribute |
|---|---|---|
timeout
|
Milliseconds of no activity before idle (default 900000). | data-stimeo--idle-timeout-value |
promptBefore
|
Milliseconds before the timeout to fire prompt (0 = no prompt). |
data-stimeo--idle-prompt-before-value |
events
|
Activity event types that reset the clock, watched passively; visibilitychange is always watched too. |
data-stimeo--idle-events-value |
Events
| Name | Description | Event |
|---|---|---|
prompt
|
Fires promptBefore ms before the timeout, with detail.remaining (ms). |
stimeo--idle:prompt |
idle
|
Fires when the timeout is reached. | stimeo--idle:idle |
active
|
Fires when the user interacts again after a prompt or idle. | stimeo--idle:active |
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-idle |
Controller element | Present (set to true) while idle; removed when activity resumes. |