Theme Toggle
stimeo--theme
Persists a light/dark/system choice and applies it to the root.
The stimeo--theme controller persists a light / dark / system choice to localStorage, follows the OS prefers-color-scheme while in system, and writes the resolved theme onto the root for the consumer's CSS — it ships no colors, only state hooks. The canonical contract is a 3-value radiogroup (use it whenever system is offered): each option is a role="radio" with aria-checked and a roving tabindex, navigable with the arrow keys (APG radio). A 2-value single button (aria-pressed) is offered only for light↔dark, since system cannot be expressed as a pressed/not-pressed toggle. It applies data-theme (resolved light/dark) and a matching color-scheme to the target (html by default), never moves focus, and watches matchMedia so system tracks live OS changes. It dispatches stimeo--theme:change and removes the matchMedia listener on disconnect (Turbo included). First-paint FOUC avoidance is a small inline <head> snippet, not this controller. Behavior only — the palette is this Playground's CSS keyed off data-theme.
Preview
This box is themed locally — the page itself is left untouched.
Keyboard
| Key | Action |
|---|---|
| → / ↓ | Moves to and selects the next theme option (radiogroup). |
| ← / ↑ | Moves to and selects the previous theme option. |
| 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-stimeo--theme-mode-param="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-stimeo--theme-mode-param="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-stimeo--theme-mode-param="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"] {
background: var(--color-primary);
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") carrying its mode param. |
data-stimeo--theme-target="option" |
Values
| Name | Description | Attribute |
|---|---|---|
mode
|
Current selection: light / dark / system (default 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). |
data-stimeo--theme-target-value |
Actions
| Name | Description | Action |
|---|---|---|
set
|
Selects the explicit mode from the mode action param. |
stimeo--theme#set |
toggle
|
Toggles light↔dark for the 2-value single-button contract. |
stimeo--theme#toggle |
Events
| Name | Description | Event |
|---|---|---|
change
|
Fires on a theme change; 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. |