Switch
stimeo--switch
An on/off switch whose state is announced to screen readers.
An on/off switch implementing the WAI-ARIA Switch pattern, so which way it is set is announced. Clicking, Space and Enter all toggle it. The change is reported, which is where you save the setting.
Keyboard
| Key | Action |
|---|---|
| Space / Enter | Toggle the switch on or off. |
<%# Markup for the switch (APG Switch) demo.
The controller element itself is a role="switch" button, with state expressed only via
aria-checked. Space/Enter and click toggle it. The look switches on aria-checked in
the Playground's CSS. %>
<button
type="button"
class="switch"
role="switch"
aria-checked="false"
data-controller="stimeo--switch"
data-action="click->stimeo--switch#toggle keydown->stimeo--switch#onKeydown">
<span class="switch__track" aria-hidden="true"><span class="switch__thumb"></span></span>
<span class="switch__label"><%= t("components.switch.demo.label") %></span>
<%# The hidden field carries the on/off state into the form; it is optional.
A hidden input is not interactive content, so it is valid inside the button. %>
<input type="hidden" name="notify" data-stimeo--switch-target="field" />
</button>
<%# Readout: what the component just reported. Purely visual — the component
already announces what a screen reader needs, so this line is aria-hidden to
keep it from being read twice. The value is a code fragment (API identifiers
and values), so only the label is translated. %>
<p class="demo-readout" aria-hidden="true">
<span><%= t("catalog.readout.field") %></span>
<span class="demo-readout__value" data-readout
data-empty-text="<%= t("catalog.readout.empty") %>">—</span>
</p>
/*
* Presentation-only styles for the switch demo.
* On/off is expressed via aria-checked (set by the library).
*/
.switch {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0;
background: transparent;
border: 0;
cursor: pointer;
font-size: 1rem;
}
.switch__track {
position: relative;
width: 2.75rem;
height: 1.5rem;
border-radius: 999px;
background: var(--border-strong);
transition: background 0.15s ease;
}
.switch[aria-checked="true"] .switch__track {
background: var(--accent);
}
.switch__thumb {
position: absolute;
top: 0.125rem;
left: 0.125rem;
width: 1.25rem;
height: 1.25rem;
border-radius: 50%;
background: var(--surface-card);
transition: transform 0.15s ease;
}
.switch[aria-checked="true"] .switch__thumb {
transform: translateX(1.25rem);
}
.switch__label {
color: var(--fg);
}
// The hidden field mirrors the value, and for a move the user made it emits the
// native `change` a form already listens to — so `stimeo--auto-submit`, form
// validation, or any `change` handler on the `<form>` reacts without knowing what
// this component is. A refresh that follows a morph or a repair is silent, and
// the component reports a value the page moved — a morph that rewrites or strips
// `aria-checked`, a host that can no longer be a switch — as
// `stimeo--switch:reconcile`. This demo reads both events, so the readout
// follows the user's moves and the page's.
// The page chrome uses some of these controllers too (the nav, the tab strip),
// so everything below is scoped to the demo itself.
const demoStage = document.querySelector(".demo-stage") ?? document;
const fieldReadout = demoStage.querySelector("[data-readout]");
const fieldSources = demoStage.querySelectorAll("[data-stimeo--switch-target='field']");
// An input without a `name` is not a submitted control, so it is left out.
const submitted = () =>
Array.from(demoStage.querySelectorAll("[data-stimeo--switch-target='field']"))
.filter((input) => input.name)
.map((input) => `${input.name}=${input.value}`)
.join(" & ") || fieldReadout?.dataset.emptyText || "";
const showSubmitted = () => {
if (fieldReadout) fieldReadout.textContent = submitted();
};
for (const source of fieldSources) source.addEventListener("change", showSubmitted);
demoStage.addEventListener("stimeo--switch:reconcile", showSubmitted);
showSubmitted();
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--switch"
Targets
| Name | Description | Attribute |
|---|---|---|
field
|
Optional hidden input the on/off state is mirrored into as true / false; valid inside the button host. |
data-stimeo--switch-target="field" |
Actions
| Name | Description | Action |
|---|---|---|
onKeydown
|
Activates the switch on Space/Enter for non-button hosts (native buttons are skipped). | stimeo--switch#onKeydown |
toggle
|
Toggles the on/off state via aria-checked. |
stimeo--switch#toggle |
Events
| Name | Description | Event |
|---|---|---|
change
|
Dispatched when the user toggles the switch, with the new checked boolean in detail. |
stimeo--switch:change |
reconcile
|
Fires when the page moves the state instead — a morph that writes or strips aria-checked, or a host that can no longer be a switch; same detail as change. Not fired on connect. |
stimeo--switch:reconcile |
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 |
|---|---|---|
aria-checked |
Root element | "true" when on, "false" when off. |