Tokens · Token families
Make your own theme
LUZ ships one look in three color modes. A theme of your own is a file of semantic tokens: start from the template, check it, load it.
Files: themes/_template.css
What a theme is
A color mode says how light or dark the page is: light, dark or high-contrast. A theme is a whole identity: colors, and optionally the fonts, radii and shadows. Every theme has the three modes.
- LUZ ships one look, Vejer, in the three modes. It is the default: with no attribute the page follows the system, dark, high contrast or light.
- A theme of your own overrides semantic tokens only. It never changes a primitive, and it never touches a component's CSS.
- You name it with
data-themeand pick its mode withdata-mode. - Overriding tokens and making a theme are the same thing: a theme is a named set of overrides, with a mode for each of its looks.
Make one, step by step
- Copy the template. It is
@luz-ds/core/themes/_template.css. Every value in it is LUZ's own, so it passes the contrast check before you change anything. - Rename it. Replace every
my-brandby the name of your theme: lowercase letters, digits and hyphens. - Change what you need. Start with the brand colors and the surfaces. Keep the three blocks complete: a token left out of the dark block takes its light value.
- Check it. Run the command in Check it until it passes.
- Load it after
luz-tokens.cssandluz.css, and set the attribute. - List it in
data-luz-themes, soapplyTheme()and the buttons of custom themes know it.<luz-theme-mode>does not need the list: it writesdata-modeand leavesdata-themeto your theme.
<link rel="stylesheet" href="luz-tokens.css">
<link rel="stylesheet" href="luz.css">
<link rel="stylesheet" href="my-brand.css">
<html lang="en" data-theme="my-brand" data-luz-themes="my-brand">
<!-- or, in its dark mode -->
<html lang="en" data-theme="my-brand" data-mode="dark" data-luz-themes="my-brand">
With no JavaScript, the radios of <luz-theme-mode> change the mode of your theme if its template is current. A copy made before needs one line more in each block, after its data-mode selector (use dark and high-contrast in the other two):
[data-theme="my-brand"][data-mode="light"],
:is(:root[data-theme="my-brand"]:not([data-mode]):has(input[name="luz-theme-mode"][value="light"]:checked)) {
A small theme can be only the tokens that differ, in the first block: what you leave out takes LUZ's value. Fill the dark and high-contrast blocks as well if you offer those modes.
[data-theme="my-brand"] {
--luz-brand-primary: #0057ff;
--luz-brand-hover: #0043c2;
--luz-brand-active: #003399;
--luz-brand-fill: #0057ff;
--luz-focus-ring: #0057ff;
--luz-surface-page: #f8faff;
}
The tokens a theme sets
The template names all of them, in each of its three blocks. They are the same tokens as the color page, with the job of each; this is the list, by group.
| Group | Tokens | What it is |
|---|---|---|
Surfaces | --luz-surface-page --luz-surface --luz-surface-hover --luz-surface-pressed --luz-surface-muted --luz-surface-strong --luz-scrim --luz-surface-media | The page, the surfaces on it, hover and pressed, and the two tones that step away from it. Text on any of them keeps 4.5:1. |
Text | --luz-text-primary --luz-text-secondary --luz-text-muted --luz-text-disabled --luz-text-on-fill | Primary and secondary text, placeholders and hints, disabled text (3:1), and the text on a solid fill. |
Lines and focus | --luz-divider --luz-outline --luz-focus-ring | Hairlines, strong borders and the focus ring. Borders and the ring keep 3:1. |
Brand | --luz-brand-primary --luz-brand-hover --luz-brand-active --luz-brand-subtle --luz-brand-fill --luz-brand-fill-hover --luz-brand-fill-active | Links and actions, their hover and active steps, a tint, and the solid fill that carries white text (4.5:1) and keeps 3:1 on the page. |
Sidebar | --luz-sidebar-bg --luz-sidebar-text --luz-sidebar-text-hover --luz-sidebar-hover --luz-sidebar-active-bg --luz-sidebar-active-text --luz-sidebar-border --luz-sidebar-label --luz-sidebar-logo-bg | The navigation drawer has its own surface and text colors, so it can be dark in a light theme. |
Status | --luz-color-success --luz-color-success-bg --luz-color-danger --luz-color-danger-bg --luz-color-danger-fill --luz-color-warning --luz-color-warning-accent --luz-color-warning-bg --luz-color-info --luz-color-info-bg | Success, danger, warning and info, each with a tint. A status color is never used for decoration. |
Fields | --luz-input-bg --luz-input-border | The background and the border of a text field. |
Extended colors and code syntax | --luz-color-teal --luz-color-teal-bg --luz-color-purple --luz-color-purple-bg --luz-color-code --luz-code-comment --luz-code-keyword --luz-code-name --luz-code-property --luz-code-string --luz-code-literal | Teal and purple with their tints, the inline code color and the six colors of highlighted code. |
Charts | --luz-chart-1 --luz-chart-2 --luz-chart-3 --luz-chart-4 --luz-chart-5 --luz-chart-6 --luz-chart-track --luz-chart-grid | The six series of a chart, the empty part of a bar and the grid. Each series keeps 3:1 on the surfaces and stays apart from the others; none is a status color. |
Optional, and not in the three blocks: --luz-font-body, --luz-font-heading and --luz-font-serif for the fonts, --luz-radius-sm and -md for the corners, and --luz-shadow-sm, -md and -lg. Set them in the first block.
Not in a theme: the sizes (--luz-type-base, --luz-type-ratio and --luz-space-unit) are set on :root, not in a theme. A theme changes colours per mode; the sizes are one page-wide choice. See Typography and Spacing.
Check it
npx @luz-ds/tokens@0.3.0 check-contrast my-brand.css
- It reads the theme as the browser would: the first block is the light mode, and each
data-modeblock lays on top of it. - The minimums are 4.5:1 for text, 3:1 for disabled text, borders, field borders and focus rings, in all three modes. Exit code
1when a pair fails. - You can also paste the theme into the contrast check in the page.
- LUZ guarantees only what it ships. Your theme is as accessible as this check says, and no more.
Layers, modes and scope
- Layers. Put the theme in
@layer luz.themesto keep it below your own CSS, or leave it unlayered to win over all of LUZ. Either way it wins overluz.tokens. The layers page has the order. - One zone.
data-themeworks on any element: the theme applies to that element and what is inside it. A date or time picker and a select take their color scheme from an ancestordata-theme, so for those put the attribute on<html>. - Forced colors. A reader who forces colors sees system colors, not your theme. Nothing to do: the components handle it.
- Print. The
luz.printlayer sets paper colors whatever the theme. - The OS preference. With no stored choice and no attribute, the page follows the system, but only for LUZ's own look: a theme of yours stays in its first (light) block unless you repeat its dark block under
@media (prefers-color-scheme: dark). To follow the system without copying the block, use<luz-theme-mode>and, to avoid a flash, its script for the head.
Limits
- One theme at a time on an element. Mix a theme and a mode, not two themes.
applyTheme()knows only the names indata-luz-themes: a name that is not listed works as an attribute, and is not remembered.light-dark()is not used: it is not Baseline Widely available before 2026-11-13.