Toast
stimeo--toast
An accessible notification queue. Implements WAI-ARIA live regions, maximum limits, and pausing on hover/focus.
The stimeo--toast controller implements highly accessible notification queues (WAI-ARIA live regions). When shown, a notification is appended to a list target. Delegated interactions pause auto-dismiss timers on hover or keyboard focus to comply with WCAG 2.2.1 (Timing Adjustable), and resume afterwards. Dynamic updates (e.g. Turbo Streams) are naturally handled via target lifecycle connections.
Keyboard
| Key | Action |
|---|---|
| Esc | Immediately dismiss the focused toast item. |
| Tab / Shift+Tab | Move focus into the toast item to pause its timer. |
<%# Markup for the toast demo.
The single source used for both the live render and the copy-paste code.
stimeo--toast provides max-count management, WAI-ARIA live region support, and
pausing the auto-dismiss timer on hover or keyboard focus.
Showing a toast is done with attributes alone (no hand-written JS):
put data-action="click->stimeo--toast#show" plus
data-stimeo--toast-body-param / data-stimeo--toast-type-param on the trigger button.
For advanced cases where you want to trigger it remotely from JS, the controller
element keeps show->stimeo--toast#show, so CustomEvent('show', { detail }) works too. %>
<div
class="toast-demo"
data-controller="stimeo--toast"
data-stimeo--toast-duration-value="5000"
data-stimeo--toast-max-value="3"
data-action="show->stimeo--toast#show">
<div class="toast-demo__triggers">
<button
type="button"
class="toast-demo__btn toast-demo__btn--status"
data-action="click->stimeo--toast#show"
data-stimeo--toast-body-param="<%= t("components.toast.demo.status_body") %>"
data-stimeo--toast-type-param="status">
<%= t("components.toast.demo.show_status") %>
</button>
<button
type="button"
class="toast-demo__btn toast-demo__btn--alert"
data-action="click->stimeo--toast#show"
data-stimeo--toast-body-param="<%= t("components.toast.demo.alert_body") %>"
data-stimeo--toast-type-param="alert">
<%= t("components.toast.demo.show_alert") %>
</button>
</div>
<%# The live region is a descendant of the controller. The controller element only
needs to contain the trigger and the list/template; role="region" goes on this
inner container. %>
<div
id="playground-toast"
role="region"
aria-label="<%= t("components.toast.demo.region_label") %>"
class="toast-container">
<%# The element where notifications are placed. %>
<ol class="toast-list" data-stimeo--toast-target="list"></ol>
<%# Template used to create new toasts. %>
<template data-stimeo--toast-target="template">
<li
class="toast-item"
data-stimeo--toast-target="item"
tabindex="0">
<div class="toast-item__content">
<span role="status" data-toast-slot="body" class="toast-item__body"></span>
<button
type="button"
class="toast-item__dismiss"
data-toast-dismiss
aria-label="<%= t("components.toast.demo.dismiss_label") %>">
<svg
class="toast-item__icon"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2">
<path d="M18 6L6 18M6 6l12 12"/>
</svg>
</button>
</div>
</li>
</template>
</div>
</div>
/*
* Presentation-only styles for the toast demo.
* Toned-down, semi-flat styling (no glassmorphism) to blend with the wireframe look
* (plain white background) of the other components.
*/
.toast-demo {
margin: 1rem 0;
}
.toast-demo__triggers {
display: flex;
gap: 0.75rem;
margin-bottom: 1.5rem;
}
.toast-demo__btn {
padding: 0.5rem 1rem;
font-size: 1rem;
cursor: pointer;
border: 1px solid var(--border-interactive);
border-radius: 0.375rem;
background: var(--surface-card);
transition: all 0.15s ease;
}
.toast-demo__btn:hover {
border-color: var(--accent);
}
.toast-demo__btn--status:hover {
border-color: var(--leaf-500);
color: var(--leaf-500);
}
.toast-demo__btn--alert:hover {
border-color: var(--danger-500);
color: var(--danger-500);
}
/* Container that positions the whole toast stack. */
.toast-container {
position: fixed;
bottom: 1.5rem;
right: 1.5rem;
z-index: 100;
pointer-events: none; /* The container as a whole is click-through. */
}
.toast-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin: 0;
padding: 0;
list-style: none;
width: min(22rem, 90vw);
}
/* Each toast item - plain white background to blend with other popups and dropdown lists. */
.toast-item {
pointer-events: auto; /* The toast item itself is clickable. */
position: relative;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 0.375rem;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
transition: all 0.2s cubic-bezier(0.16, 1, 0.3, 1);
overflow: hidden;
outline: none;
}
.toast-item__body[role="alert"]::before,
.toast-item__body[role="status"]::before {
position: absolute;
inset-block: 0;
inset-inline-start: 0;
width: 4px;
content: "";
}
.toast-item__body[role="alert"]::before {
background: var(--danger-500);
}
.toast-item__body[role="status"]::before {
background: var(--leaf-500);
}
.toast-item:focus-visible {
outline: 2px solid var(--accent);
outline-offset: -2px;
}
/* Paused state when data-paused="true" (slightly gray). */
.toast-item[data-paused="true"] {
background: var(--surface-subtle);
}
/* Lifecycle states used for transitions. */
.toast-item[data-state="entering"] {
opacity: 0;
transform: translateY(0.5rem);
}
.toast-item[data-state="visible"] {
opacity: 1;
transform: translateY(0);
}
.toast-item[data-state="leaving"] {
opacity: 0;
transform: translateY(-0.25rem);
}
.toast-item__content {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem 1rem;
gap: 1rem;
}
.toast-item__body {
font-size: 0.9rem;
color: var(--fg);
line-height: 1.4;
flex-grow: 1;
}
.toast-item__dismiss {
display: flex;
align-items: center;
justify-content: center;
width: 1.5rem;
height: 1.5rem;
padding: 0;
background: transparent;
border: none;
color: var(--color-text-subtle);
border-radius: 9999px;
cursor: pointer;
transition: all 0.15s ease;
}
.toast-item__dismiss:hover {
background: var(--surface-subtle);
color: var(--color-text-muted);
}
.toast-item__icon {
width: 0.85rem;
height: 0.85rem;
}
This demo needs no consumer-side JS (the controller handles the behavior).
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--toast"
Targets
| Name | Description | Attribute |
|---|---|---|
list
required
|
The list (e.g. <ol>) that new toast items are appended to. |
data-stimeo--toast-target="list" |
template
|
The <template> cloned to create each toast, with a body slot to interpolate the message. |
data-stimeo--toast-target="template" |
item
|
A listitem toast whose nested body has role="status"/alert; pauses on hover/focus, is dismissible, and tracks data-state for transitions. |
data-stimeo--toast-target="item" |
Values
| Name | Description | Attribute |
|---|---|---|
duration
|
Auto-dismiss timeout in milliseconds; 0 disables auto-dismiss (default 0). | data-stimeo--toast-duration-value |
max
|
Maximum simultaneous toasts; the oldest are removed past this limit (default 3). | data-stimeo--toast-max-value |
Actions
| Name | Description | Action |
|---|---|---|
dismiss
|
Dismisses the toast containing the trigger (with reason user). |
stimeo--toast#dismiss |
onKeydown
|
Dismisses the focused toast when Escape is pressed. | stimeo--toast#onKeydown |
pause
|
Pauses the auto-dismiss timer on hover or focus, snapshotting the remaining time (WCAG 2.2.1). | stimeo--toast#pause |
resume
|
Resumes the auto-dismiss timer once both hover and focus have been released. | stimeo--toast#resume |
show
|
Clones the template, interpolates body text from a param or event detail, sets the live-region role, and appends a new toast. | stimeo--toast#show |
Events
| Name | Description | Event |
|---|---|---|
dismiss
|
Fires when a toast is removed; detail carries the item element and reason (user or timeout). |
stimeo--toast:dismiss |
show
|
Fires when a new toast is shown; detail carries the created item element. | stimeo--toast:show |
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 |
|---|---|---|
role="status" |
Toast message | Announced by screen readers as a polite status message (default). |
role="alert" |
Toast message | Announced by screen readers as an assertive, urgent message. |
data-paused="true" |
Toast item | Indicates the auto-dismiss timer is paused due to hover or focus. |
data-state="entering | visible | leaving" |
Toast item | Reflects the current lifecycle state, allowing CSS transition integration. |