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-active class 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. Its aria-label ("Color mode") describes the action.
  • The icons inside each button carry aria-hidden="true"; the button's aria-label is 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>