Theme Toggle
stimeo--theme
Remembers a light, dark or match-my-device choice and applies it to the whole page.
Remembers which colour scheme was chosen and brings it back on the next visit. There are three choices: light, dark, and matching the device. While matching the device, a change in the operating system's setting is followed immediately. The resolved scheme is written on the root of the page, so the colours themselves stay in your CSS: this part ships none. With three choices it is built as a group of options, so the arrow keys select and screen readers hear it correctly. For light and dark alone, a single button holding a pressed state works too.
Preview
This box is themed locally — the page itself is left untouched.
Keyboard
| Key | Action |
|---|---|
| → / ↓ | Moves to and selects the next theme option (radiogroup). Under RTL only the horizontal pair swaps. |
| ← / ↑ | Moves to and selects the previous theme option. Under RTL only the horizontal pair swaps. |
| Home / End | Selects the first / last theme option. |
| Enter / Space | Activates the focused option (or toggles the single button). |
<%# Markup for the theme / color-scheme toggle demo.
The controller persists the light/dark/system choice, follows the OS while in
system, and writes data-theme + color-scheme onto the target. To avoid theming the
whole Playground, this demo targets a local preview element instead of <html>, so
the preview box below reacts to the resolved theme while the page is unaffected. %>
<div class="theme-demo">
<div class="theme-demo__switcher" data-controller="stimeo--theme"
data-stimeo--theme-target-value="#theme-demo-preview"
data-stimeo--theme-storage-key-value="stimeo-theme-demo"
data-stimeo--theme-mode-value="system"
role="radiogroup" aria-label="<%= t("components.theme.demo.aria_label") %>">
<button type="button" class="theme-demo__option"
data-stimeo--theme-target="option" role="radio"
data-action="click->stimeo--theme#set" data-value="light">
<%= t("components.theme.demo.light") %>
</button>
<button type="button" class="theme-demo__option"
data-stimeo--theme-target="option" role="radio"
data-action="click->stimeo--theme#set" data-value="dark">
<%= t("components.theme.demo.dark") %>
</button>
<button type="button" class="theme-demo__option"
data-stimeo--theme-target="option" role="radio"
data-action="click->stimeo--theme#set" data-value="system">
<%= t("components.theme.demo.system") %>
</button>
</div>
<div id="theme-demo-preview" class="theme-demo__preview">
<h3 class="theme-demo__preview-title"><%= t("components.theme.demo.preview_title") %></h3>
<p><%= t("components.theme.demo.preview_text") %></p>
</div>
</div>
/*
* Presentation-only styles for the theme toggle demo.
* The library writes data-theme (resolved light/dark) and color-scheme onto the
* preview element and keeps aria-checked in sync; this CSS owns the palette keyed
* off data-theme — exactly the consumer's job. No colors ship in the library.
*/
.theme-demo {
display: flex;
flex-direction: column;
gap: 1rem;
max-width: 32rem;
}
.theme-demo__switcher {
display: inline-flex;
gap: 0.25rem;
padding: 0.25rem;
border: 1px solid var(--border);
border-radius: 0.5rem;
width: fit-content;
}
.theme-demo__option {
padding: 0.4rem 0.8rem;
border: 0;
border-radius: 0.375rem;
background: transparent;
cursor: pointer;
font-size: 0.9rem;
}
/* The library marks the selected option with aria-checked="true". */
.theme-demo__option[aria-checked="true"] {
/* Same pairing as clipboard's button: white over the brand primary is 2.99:1,
so the filled state uses the accessible fill token. */
background: var(--accent-fill);
color: var(--white);
}
/* The preview reacts to the resolved theme the library applied. */
.theme-demo__preview {
padding: 1.25rem;
border-radius: 0.75rem;
border: 1px solid var(--border-default);
background: var(--surface-card);
color: var(--color-text);
}
.theme-demo__preview[data-theme="dark"] {
border-color: var(--border-strong);
background: var(--slate-900);
color: var(--slate-200);
}
.theme-demo__preview-title {
margin: 0 0 0.5rem;
font-size: 1.05rem;
}
// Consumer-side JS for the theme toggle demo (demo-only).
// The controller persists the choice and applies data-theme on its own; this only
// logs the change event to show the contract (mode = chosen, resolved = effective).
document.addEventListener("stimeo--theme:change", (event) => {
console.log(`[theme] mode=${event.detail.mode} resolved=${event.detail.resolved}`);
});
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--theme"
Targets
| Name | Description | Attribute |
|---|---|---|
option
|
A radiogroup option (role="radio") declaring its mode with data-value (light / dark / system). |
data-stimeo--theme-target="option" |
Values
| Name | Description | Attribute |
|---|---|---|
mode
|
Current selection: light / dark / system (default system). A declaration outside the three reads as system. |
data-stimeo--theme-mode-value |
storageKey
|
localStorage key for persistence (default stimeo-theme). |
data-stimeo--theme-storage-key-value |
target
|
Selector for the element the state hooks are written to (default html). A selector that cannot be parsed falls back to it. |
data-stimeo--theme-target-value |
Actions
| Name | Description | Action |
|---|---|---|
set
|
Selects the mode the activated option declares with data-value; an option declaring anything else is never selected. |
stimeo--theme#set |
toggle
|
Toggles light↔dark for the 2-value single-button contract. |
stimeo--theme#toggle |
Events
| Name | Description | Event |
|---|---|---|
change
|
Fires only when the theme actually changes; detail carries mode and resolved. |
stimeo--theme:change |
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-theme |
Target element (html by default) | The resolved effective theme: "light" or "dark". |
color-scheme |
Target element | Native UI color-scheme hint matching the resolved theme. |
aria-checked |
Radiogroup option | "true" on the selected option, "false" on the others. |
aria-pressed |
Single toggle button (2-value) | "true" when the resolved theme is dark. |