Color mode
Part of the extras bundle, not included by default.
Intent
A three-state color-mode switcher: light, system, dark. The
light and dark states force a
theme class on <html>;
system removes both and defers to
prefers-color-scheme via brio's media-query token
layer. The user's choice persists in localStorage
under the brio-color-mode key and applies
immediately on subsequent visits.
The component is a custom element. With no children it auto-renders a row of pill buttons and a single cycle button (one of the few brio elements that creates DOM rather than orchestrating it -- documented in the source as a deliberate drop-in convenience). Consumers who want their own icons or labels supply authored markup instead.
Color-mode switching is a project-preference concern, not a
library concern, so the component ships from
extras rather than core. Sites that want a switcher
opt in by loading brio-extras.js; sites that don't
pay nothing.
Basic usage
Drop the bare element anywhere chrome-shaped and the auto-rendered
pill appears. The header of this page already includes one;
clicking buttons in the demo below also switches the docs site's
theme, because applyTheme() updates every
<brio-color-mode> on the page (sync state is
the design, not a quirk).
<brio-color-mode></brio-color-mode>Shape: default group
The default shape is a three-button group. Each button announces
its mode via aria-label; the active mode binds to
aria-pressed="true". The chrome rides on
.button.is-neutral.is-outlined, and the active state
inherits .button-group's pressed recipe -- a quiet
family fill plus a neutral border -- so the selection reads as a
config marker rather than a navigational landing.
<brio-color-mode></brio-color-mode>Shape: cycle button
Add .as-cycle to render a single cycle button at all
sizes. The button advances light → system → dark and
wraps. The visible icon matches the current mode -- CSS reveals
only the icon whose data-icon-for attribute matches
the parent's data-current-mode. The group is
skipped; the cycle button stands alone.
<brio-color-mode class="as-cycle"></brio-color-mode>Shape: responsive (pill above 48rem, cycle below)
Add .as-cycle-below-md to render the pill at desktop
widths and the cycle button on narrow viewports. The breakpoint
matches the docs site's small / medium boundary. The threshold is
a viewport media query rather than a container query, so the
swap follows browser width -- resize the window below 48rem to
see the demo flip to the cycle shape.
<brio-color-mode class="as-cycle-below-md"></brio-color-mode>Authored chrome
To supply custom icons or labels, author the buttons inside the
element. The auto-render branch is skipped when the element has
children. Use data-theme-value to mark each button's
mode (light, system, dark,
or cycle for the wrapping cycle button). The cycle
button needs three <span data-icon-for>
children; the JS sets data-current-mode on the
parent so CSS can reveal the matching icon.
<brio-color-mode>
<span class="button-group">
<button class="button is-neutral is-outlined is-sm as-icon" type="button" data-theme-value="light" aria-label="Light mode" aria-pressed="false">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<circle cx="12" cy="12" r="5"></circle>
<line x1="12" y1="1" x2="12" y2="3"></line>
<line x1="12" y1="21" x2="12" y2="23"></line>
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64"></line>
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78"></line>
<line x1="1" y1="12" x2="3" y2="12"></line>
<line x1="21" y1="12" x2="23" y2="12"></line>
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36"></line>
<line x1="18.36" y1="5.64" x2="19.78" y2="4.22"></line>
</svg>
</button>
<button class="button is-neutral is-outlined is-sm as-icon" type="button" data-theme-value="system" aria-label="System mode" aria-pressed="false">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<rect x="2" y="3" width="20" height="14" rx="2" ry="2"></rect>
<line x1="8" y1="21" x2="16" y2="21"></line>
<line x1="12" y1="17" x2="12" y2="21"></line>
</svg>
</button>
<button class="button is-neutral is-outlined is-sm as-icon" type="button" data-theme-value="dark" aria-label="Dark mode" aria-pressed="false">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"></path>
</svg>
</button>
</span>
</brio-color-mode>No-flash init
Without an init step, dark-mode users see a brief flash of the
light theme before brio-extras.js loads and applies
the saved mode. To eliminate the flash, run a tiny synchronous
snippet in <head> before the
stylesheet links: it reads the saved mode and sets the theme
class on <html> ahead of first paint.
The snippet is intentionally framework-free and safe under Content Security Policy if you allow inline scripts in the page head (or move it to an external file with a stable hash).
<script>
(function () {
try {
const m = localStorage.getItem("brio-color-mode");
if (m === "light") document.documentElement.classList.add("light-theme");
else if (m === "dark") document.documentElement.classList.add("dark-theme");
} catch (e) { /* localStorage unavailable -- noop */ }
})();
</script>
Programmatic API
Named exports cover the cases where consumer JS needs to
drive the mode directly: applyTheme(mode) sets the
theme (and updates every <brio-color-mode> on
the page so all switchers stay in sync); loadTheme()
returns the persisted mode (defaults to "system").
import { applyTheme, loadTheme } from "/assets/brio-extras.js";
applyTheme("dark"); // force dark
applyTheme(loadTheme()); // re-apply persisted choice
// Or, for consumers who want to skip the aggregator and import a
// single extra, point at the prod-only standalone:
// import { applyTheme, loadTheme } from "/extras/brio-color-mode.js";
//
// Or use the global namespace from a non-module context:
// brio.colorMode.applyTheme("dark");
Customization
The chrome is just .button.is-neutral.is-outlined.is-sm.as-icon
inside a .button-group, so size, radius, hover, and
focus all flow through .button's own tokens. The
pressed visual comes from .button-group's recipe --
a quiet family fill plus a neutral border on the selected button.
Retone by overriding .button's scoped tokens (e.g.,
--button-radius) on brio-color-mode or a
parent scope, or by swapping the inner buttons' color variant if
you'd rather render in primary or accent. See
src/css/button.css for the full token list.
The default chrome is opinionated:
is-neutral.is-outlined.is-sm.as-icon. To render the
auto-generated buttons with a different look, set
button-class on the host. The attribute replaces the
default class string on every generated button, so you can dress
the switcher in any recipe you want -- a filled primary variant, a
larger size, a different shape -- without hand-authoring the
markup. The component's structural CSS keys off
data-theme-value and the .button-group
wrapper rather than the presentation classes, so the cycle-icon
swap and the responsive group / cycle swap keep working whatever
you set. The attribute applies only to auto-rendered chrome;
authored markup already carries whatever classes you write.
Accessibility
-
Each pill button is a real
<button>, not a styled<div>-- keyboard reachable and announceable by default. -
Active state binds to
aria-pressed="true"on the pill button matching the current mode. CSS targets the attribute; no.is-activeclass is involved. -
Each button carries an explicit
aria-label("Light mode", "Dark mode", etc.) so the button announces its purpose even when the visible content is icon-only. -
The cycle button is not a toggle -- it advances state on
each click rather than holding a binary value -- so it has
no
aria-pressed. Itsaria-label("Color mode") describes the action. -
The icons inside each button carry
aria-hidden="true"; the button'saria-labelis the announcement.
Markup
<!-- Auto-rendered (default) -->
<brio-color-mode></brio-color-mode>
<!-- Auto-rendered with shape variant -->
<brio-color-mode class="as-cycle"></brio-color-mode>
<brio-color-mode class="as-cycle-below-md"></brio-color-mode>
<!-- Auto-rendered with a different button look -->
<brio-color-mode button-class="button is-primary is-sm as-icon"></brio-color-mode>
<!-- Authored chrome (bring your own icons / labels). Mirror the
auto-render structure: pills wrapped in a .button-group, the
cycle button as a sibling outside the group. -->
<brio-color-mode>
<span class="button-group">
<button class="button is-neutral is-outlined is-sm as-icon" type="button" data-theme-value="light" aria-label="Light mode" aria-pressed="false">
<svg aria-hidden="true">...</svg>
</button>
<button class="button is-neutral is-outlined is-sm as-icon" type="button" data-theme-value="system" aria-label="System mode" aria-pressed="false">
<svg aria-hidden="true">...</svg>
</button>
<button class="button is-neutral is-outlined is-sm as-icon" type="button" data-theme-value="dark" aria-label="Dark mode" aria-pressed="false">
<svg aria-hidden="true">...</svg>
</button>
</span>
<button class="button is-neutral is-outlined is-sm as-icon" type="button" data-theme-value="cycle" aria-label="Color mode">
<span data-icon-for="light"><svg aria-hidden="true">...</svg></span>
<span data-icon-for="system"><svg aria-hidden="true">...</svg></span>
<span data-icon-for="dark"><svg aria-hidden="true">...</svg></span>
</button>
</brio-color-mode>