Skip to main content

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.

HTML
<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 attributeEffect
<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__group
The fieldset of radios; without the button, a row of options.
.luz-theme-mode__legend, .luz-theme-mode__option, .luz-theme-mode__input, .luz-theme-mode__icon
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

NameWhat 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--fixed to the fixed button and leaves it off with inline. A focused radio passes the focus to the button.
  • Auto follows the system, also while the page is open: prefers-contrast: more gives high-contrast, else prefers-color-scheme: dark gives dark, else light.
  • What it writes on <html>: the mode in data-mode, and with no brand the same value in data-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 to data-mode.
  • The choice is remembered under luz-theme in localStorage; Auto removes it and no-remember turns it off. If the script for the head has written data-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 adds scroll-padding-block-end to <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:

HTML
<head>
  <meta charset="utf-8">
  <script src="/theme-boot.min.js"></script>
  <link rel="stylesheet" href="…">
</head>
  • What it writes: data-mode on <html>, and data-theme with the same value unless the page names a brand. The mode is the one stored under luz-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 in script-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

Do Name the modes for what they are: “Auto”, “Light”, “Dark”, “High contrast”.
Don't Call the high-contrast mode “Accessible”: it says the other modes are not, and does not say what changes.
Do One fixed button at the end corner, with the radios inside the element for readers without JavaScript.
Don't Put a second fixed control at the same corner: they cover each other, and one is hidden when the other is on top.

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.

PartColour on backgroundLightDarkHigh contrastNeeds
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-ring on the page and --luz-sidebar-text-hover in 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 is ButtonText and the shadow is dropped.
  • Target: each option is at least 28 px high; the round button is --luz-control-lg wide 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.

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.