Skip to main content

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-theme and pick its mode with data-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

  1. 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.
  2. Rename it. Replace every my-brand by the name of your theme: lowercase letters, digits and hyphens.
  3. 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.
  4. Check it. Run the command in Check it until it passes.
  5. Load it after luz-tokens.css and luz.css, and set the attribute.
  6. List it in data-luz-themes, so applyTheme() and the buttons of custom themes know it. <luz-theme-mode> does not need the list: it writes data-mode and leaves data-theme to your theme.
HTML
<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):

CSS
[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.

CSS
[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.

Semantic tokens in the template
GroupTokensWhat 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

Shell
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-mode block 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 1 when 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.themes to keep it below your own CSS, or leave it unlayered to win over all of LUZ. Either way it wins over luz.tokens. The layers page has the order.
  • One zone. data-theme works 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 ancestor data-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.print layer 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 in data-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.