Core · Navigation
Color mode
Lets readers choose Auto, light, dark or high contrast: radios that work without JavaScript, or one round button that steps through them.
Example
The radio group, the round button fixed at the end corner of its stage, and the same button in the flow. They are pictures of the states: they sit in no element and are inert, so they do not change this page. The working button is the one at the end corner of this window.
Radio group
Button, fixed at the end corner
Button, inline
Without JavaScript the radios show in the flow, as in the first stage, and there is no button.
Known issues
What to expect:
- Without the script for the head, a dark visitor sees the page light for a moment first: the element runs after the first paint.
- Auto follows the system setting only. To let a reader pick the time of day too, add
<luz-theme-time>from the catalog. - The text is yours: LUZ writes no label, so translate the legend and the modes with the page.
- It has not been tested with a screen reader.
Code
Copy the markup
Write one radio for each choice inside <luz-theme-mode>, in the order the button steps through them. Put an SVG icon (currentColor, aria-hidden) in each label: the button shows the checked one. The legend and the labels are yours.
<script type="module" src="https://cdn.jsdelivr.net/npm/@luz-ds/core@0.3.0/dist/luz.min.js"></script>
<luz-theme-mode>
<fieldset class="luz-theme-mode__group">
<legend class="luz-theme-mode__legend">Color mode</legend>
<label class="luz-theme-mode__option"><input class="luz-theme-mode__input" type="radio" name="luz-theme-mode" value="auto" checked><svg class="luz-theme-mode__icon" aria-hidden="true">…</svg>Auto</label>
<label class="luz-theme-mode__option"><input class="luz-theme-mode__input" type="radio" name="luz-theme-mode" value="light"><svg class="luz-theme-mode__icon" aria-hidden="true">…</svg>Light</label>
<label class="luz-theme-mode__option"><input class="luz-theme-mode__input" type="radio" name="luz-theme-mode" value="dark"><svg class="luz-theme-mode__icon" aria-hidden="true">…</svg>Dark</label>
<label class="luz-theme-mode__option"><input class="luz-theme-mode__input" type="radio" name="luz-theme-mode" value="high-contrast"><svg class="luz-theme-mode__icon" aria-hidden="true">…</svg>High contrast</label>
</fieldset>
</luz-theme-mode>
luz.min.js loads theme-mode.min.js when the page has the element. To load it yourself, or for an element added after the page has loaded, import "@luz-ds/core/theme-mode";.
Variants and options
| Class or attribute | Effect |
|---|---|
<luz-theme-mode> | The element. It holds the radios; with scripting on it adds one round button, fixed at the end corner. |
inline | A boolean. The button stays where the markup puts it: a footer, a navbar, a sidebar. |
no-remember | A boolean. The choice is not read or stored under luz-theme: the page starts in Auto. |
name="luz-theme-mode" | Keep it: without JavaScript the tokens read it to change the mode. |
value | auto, light, dark or high-contrast. The button steps through the radios in markup order and skips a disabled one. |
.luz-theme-mode | The fieldset of radios; without the button, a row of options. |
.luz-theme-mode, .luz-theme-mode, .luz-theme-mode, .luz-theme-mode | The legend (it names the group and, with the checked label, the button); a label with its radio, icon and name; the radio, visually hidden and focusable; the icon, which the button copies from the checked option. |
__button, __button--fixed, __status | Written by the script, not by you: the round button (fixed unless inline) and a hidden live region that says the new mode. |
Properties, methods and event
| Name | What it does |
|---|---|
el.choice | The choice ( auto, light, dark, high-contrast). Setting it chooses that radio; other values are ignored. |
el.mode | The mode in use. In Auto it is the system's. |
el.refresh() | Applies the mode again. Call it after you change what Auto means. |
el.autoMode | A function that returns light, dark or nothing (the system's). Another script sets it to own what Auto means, as <luz-theme-time> does; the system's high contrast still wins. |
luz-theme-mode:change | Bubbles after the upgrade and on every change. detail: mode, auto (the choice is Auto) and user (a person chose it). |
How it works
A native radio group, styled by components/theme-mode.css. The radios are the state; with scripting on, the element turns them into one button.
- The button: each press checks the next radio and applies it; the group is hidden while it is there. The script adds
luz-theme-mode__button--fixedto the fixed button and leaves it off withinline. A focused radio passes the focus to the button. - Auto follows the system, also while the page is open:
prefers-contrast: moregiveshigh-contrast, elseprefers-color-scheme: darkgivesdark, elselight. - What it writes on
<html>: the mode indata-mode, and with no brand the same value indata-theme, as the tokens read it. A page that names a brand (data-theme="my-brand", a theme of your own made from_template.css) keeps it, and the blocks of the brand answer todata-mode. - The choice is remembered under
luz-themeinlocalStorage; Auto removes it andno-rememberturns it off. If the script for the head has writtendata-mode, the first Auto adopts it. A second<luz-theme-mode>follows the first. - Where the button sits: unless
inline, at the end corner of the window (bottom left in a right-to-left page), at--luz-z-raised: above the content, below the sidebar, the dialogs and the toasts. - Keep the corner free. Give the footer an end padding of
calc(var(--luz-control-lg) + var(--luz-space-md) * 2)under@media (scripting: enabled). The stylesheet addsscroll-padding-block-endto<html>, so Tab never leaves a focused element under the button; over a showing<luz-cookie-consent>the button rides above the banner. - In a sidebar it takes the colours of catalog's sidebar; elsewhere on a dark surface, set the
--luz-theme-mode-*properties.
Without JavaScript
The four radios show where the markup puts them; there is no button. Choosing one changes the mode, because the tokens read the checked radio (:root:has()), until <html> has a data-mode. It works for a brand theme too, if its _template.css is current. The choice is not remembered, and Auto follows the system: dark under prefers-color-scheme: dark, high contrast under prefers-contrast: more, light otherwise.
Keyboard and ARIA
- Button: Tab reaches it; Enter or Space moves to the next mode. Focus follows the markup, not the corner.
- Name and status: the button's name is the text of the legend, a colon and the label of the checked option, such as “Color mode: Dark”. After a person chooses, a hidden
role="status"region says the same; it says nothing when the page loads. - Radios: without the button, Tab reaches the group and the arrow keys choose a mode.
Script for the head
Optional. It sets the mode before the first paint, so a dark visitor never sees the page light first. Copy node_modules/@luz-ds/core/dist/theme-boot.min.js to your own origin and link it in the <head>, before the stylesheets:
<head>
<meta charset="utf-8">
<script src="/theme-boot.min.js"></script>
<link rel="stylesheet" href="…">
</head>
- What it writes:
data-modeon<html>, anddata-themewith the same value unless the page names a brand. The mode is the one stored underluz-theme, else the system's (as Auto, above). It never writes the storage. - Why it blocks rendering: it is a classic script, not a module, and a few hundred bytes: the browser runs it before the first paint. That is the one documented exception to no JavaScript on the critical path (EX-009), and a site opts in. Without it the element writes the same attributes after the first paint, so a dark visitor sees the page light for a moment.
- Your own origin: under a Content-Security-Policy (CSP) without
'unsafe-inline', serve the file from your own origin. A blocking script from another host delays the first paint and needs that host inscript-src.
Extend it with the catalog: day moments and the bar
<luz-theme-time>, in @luz-ds/catalog, wraps this element. It adds the time of day as a choice, in moments you declare (dawn, morning, sunset), and can add a slider of the 24 hours. Auto stays the system’s. This element does not need it: if the catalog does not load, the button works as it does here.
Contrast and focus are in the Design tab.
Design
When to use
Offer a color mode control on every page of a site, in the same place.
- The round button at the end corner, once per page.
- The round button with
inline, where it belongs to a panel: a footer, a navbar, a sidebar.
When not to use
Do not put a second fixed button on a page: the others are inline.
- Do not fix it at a corner another control holds, such as a chat button.
- Do not leave the radios out of the markup: without JavaScript they are the only control.
- Do not use it to choose a brand: a brand is named with
data-theme; this element chooses its mode.
Do and don't
Writing the content
Call the group “Color mode” and the choices “Auto”, “Light”, “Dark” and “High contrast”.
- Each choice keeps its visible name next to its icon, so the radios read without the icons.
- The button's name is the legend and the checked label, “Color mode: Dark”. Write both so that they read well together, and translate them with the page: LUZ writes none of this text.
- Say near the control what Auto follows: “Auto follows your device”.
Contrast and focus
On the page the checked option is filled with the brand; in the sidebar it takes the sidebar's current-link colours. The round button is a surface with its icon: the checked mode is its icon, never a fill.
| Part | Colour on background | Light | Dark | High contrast | Needs |
|---|---|---|---|---|---|
Group label | --luz-text-secondary on --luz-surface-page | 6.2:1 | 7.5:1 | 19.3:1 | 4.5:1 |
Option name, hover | --luz-text-primary on --luz-surface-hover | 14.4:1 | 12.6:1 | 20.1:1 | 4.5:1 |
Option border | --luz-outline on --luz-surface-page | 4.3:1 | 3.6:1 | 21.0:1 | 3:1 |
Round button icon | --luz-text-secondary on --luz-surface | 6.4:1 | 6.9:1 | 19.3:1 | 3:1 |
Round button icon, hover | --luz-text-secondary on --luz-surface-hover | 5.6:1 | 6.2:1 | 18.4:1 | 3:1 |
Checked option | --luz-text-on-fill on --luz-brand-fill | 6.0:1 | 4.9:1 | 14.1:1 | 4.5:1 |
Checked fill | --luz-brand-fill on --luz-surface-page | 5.6:1 | 3.5:1 | 14.1:1 | 3:1 |
Focus ring | --luz-focus-ring on --luz-surface-page | 5.6:1 | 7.7:1 | 21.0:1 | 3:1 |
Group label, in the sidebar | --luz-sidebar-label on --luz-sidebar-bg | 6.7:1 | 6.6:1 | 6.4:1 | 4.5:1 |
Checked option, in the sidebar | --luz-sidebar-active-text on --luz-sidebar-active-bg | 10.3:1 | 10.2:1 | 21.0:1 | 4.5:1 |
Checked fill, in the sidebar | --luz-sidebar-active-bg on --luz-sidebar-bg | 8.9:1 | 10.2:1 | 21.0:1 | 3:1 |
Focus ring, in the sidebar | --luz-sidebar-text-hover on --luz-sidebar-bg | 15.7:1 | 18.9:1 | 21.0:1 | 3:1 |
Measured from the token values of each mode, rounded down. Needs is the WCAG 2.2 AA minimum: 4.5:1 for text, 3:1 for focus rings, borders and icons.
- Focus: a 2 px outline, 2 px outside the whole option or the round button, in
--luz-focus-ringon the page and--luz-sidebar-text-hoverin the sidebar. - Not colour alone: every option keeps its visible name; in forced colors the checked one is a system highlight with a 2 px border, and a disabled one has a dashed border.
- Edge: the hairline of the round button (
--luz-divider) is a decoration: the icon, 3:1 or more, is what tells the control apart. In forced colors the edge isButtonTextand the shadow is dropped. - Target: each option is at least 28 px high; the round button is
--luz-control-lgwide and high. - Not hidden: the page keeps the corner free at its end and scrolls a focused element out from under the button (WCAG 2.4.11).
Tokens it uses
The 22 tokens that components/theme-mode.css reads, by family. Each family links to its page, with the values in every mode. In Figma they are variables with the same names: see Tokens in Figma.
- Color
--luz-brand-fill,--luz-brand-primary,--luz-brand-subtle,--luz-divider,--luz-focus-ring,--luz-outline,--luz-sidebar-text-hover,--luz-surface,--luz-surface-hover,--luz-text-on-fill,--luz-text-secondary- Spacing and sizing
--luz-control-lg,--luz-space-2xs,--luz-space-md,--luz-space-xs- Typography
--luz-font-mono,--luz-text-xs- Radius
--luz-radius-full,--luz-radius-md- Shadows
--luz-shadow-sm- Motion
--luz-duration-fast- Layers
--luz-z-raised
Keyboard and ARIA are in the Code tab.