Skip to content

Theming

NuvoUI ships a light and a dark theme. A theme is a set of values for the role and theme tokens (--nu-bg-surface, --nu-text-muted, --nu-brand and the rest); palette, basic and scale tokens are the same in every theme. Because classes and mixins output tokens, switching the theme restyles everything without changing a class.

Set data-theme on the root element, or on any element to theme only its subtree.

Attribute Tokens
data-theme="light" Light values
data-theme="dark" Dark values
data-theme="inverse" The opposite of the surrounding theme (see Inverse sections)
No data-theme on :root Light values, or dark values when the operating system prefers a dark colour scheme
<!-- Follows the operating system -->
<html>
<!-- Always dark -->
<html data-theme="dark">

The emitted selectors:

:root {
/* fixed tokens: palette, basic colours, scales, font family */
}
:root,
[data-theme="light"] {
/* light values: theme and role tokens */
}
[data-theme="dark"],
[data-theme="inverse"] {
/* dark values: theme and role tokens only */
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme]) {
/* dark values */
}
}

Fixed tokens are declared once, on :root only, so a runtime override such as :root { --nu-font-family: ... } also applies inside a [data-theme="light"] section.

The operating system fallback applies only to a root element without data-theme. To let users override the system setting, set data-theme="light" or data-theme="dark" on html from your theme switcher, and remove the attribute to follow the system again. There is no data-theme="system" value.

Any element can carry data-theme. Its subtree uses that theme’s tokens.

data-theme="light"
bg-surface card
data-theme="dark"
bg-surface card
<div class="grid cols-2 gap-4">
<div data-theme="light" class="bg-base text-regular p-4 rounded border">
<div class="font-semibold">data-theme="light"</div>
<div class="bg-surface border rounded p-2 mt-2">bg-surface card</div>
<button type="button" class="mt-2">Brand button</button>
</div>
<div data-theme="dark" class="bg-base text-regular p-4 rounded border">
<div class="font-semibold">data-theme="dark"</div>
<div class="bg-surface border rounded p-2 mt-2">bg-surface card</div>
<button type="button" class="mt-2">Brand button</button>
</div>
</div>

Give a themed element its own background and text colour (bg-base text-regular, or bg-surface). Inherited properties such as color arrive already computed from the parent’s theme, so text inside a nested theme keeps the parent’s colour unless something in the subtree sets it again.

data-theme="inverse" gives a section the opposite theme of the page: dark values on a light page, light values on a dark page. Use it for a feature band, a footer or a callout that should stand out in both themes, with the normal classes inside it.

Switch the site theme to see the section flip

The page theme

data-theme="inverse"

Always the opposite of the page around it.

<div class="bg-surface border rounded p-4 space-y-3">
<p class="mb-0">The page theme</p>
<section data-theme="inverse" class="bg-base text-regular rounded p-4">
<div class="font-semibold">data-theme="inverse"</div>
<p class="text-muted mb-2">Always the opposite of the page around it.</p>
<button type="button">Brand button</button>
</section>
</div>

The selectors that make this work:

Context Selector Values
Light page [data-theme="inverse"] Dark
Explicit dark page [data-theme="dark"] [data-theme="inverse"] Light
Dark from the operating system :root:not([data-theme]) [data-theme="inverse"], inside prefers-color-scheme: dark Light
Inverse inside inverse [data-theme="inverse"] [data-theme="inverse"], and the same one level deeper under a dark page The page theme again

Every theme token is set inside the section, so every class and mixin works there. Inverse sections are not emitted when $enable-dark-mode is false.

An inverse section inside another inverse section flips back: on a light page, the outer section is dark and the inner one is light again. This works two levels deep, on a light page, an explicit dark page and an operating system dark page.

<html data-theme="dark">
<section data-theme="inverse">
Light values
<div data-theme="inverse">Dark values again</div>
</section>
</html>

An inverse section resolves against the page or the nearest data-theme="dark" ancestor and the inverse sections above it, not against a data-theme="light" section. Inside a data-theme="light" section on a dark page, data-theme="inverse" gives the light values, because the dark page is the nearest dark ancestor. Set data-theme="dark" explicitly in that case.

<html data-theme="dark">
<section data-theme="light">
<div data-theme="inverse">Light values, not dark</div>
<div data-theme="dark">Use this for a dark block here</div>
</section>
</html>

data-contrast="high" applies a high contrast variant of the theme. Its values come from two maps, one per theme:

Variable Applies in
$high-contrast-light Light contexts only: data-contrast="high" together with data-theme="light" on the same element or an ancestor; a root with data-contrast="high" and no data-theme when the operating system is light; inverse sections that resolve to light
$high-contrast-dark Dark contexts: data-contrast="high" together with data-theme="dark" or data-theme="inverse"; a root with data-contrast="high" and no data-theme when the operating system is dark

Every theme selector, including inverse and nested inverse sections and the operating system fallback, has a higher-specificity twin compounded with :is([data-contrast="high"], [data-contrast="high"] *), so the twin that wins is always the twin of the theme block that wins. The light-valued and dark-valued twins carry the same keys (the keys of both maps); a key that a twin’s own map lacks keeps the base theme value, so a key set in only one map never leaks into the other theme. An element without data-theme inherits its tokens and is not restyled by its own data-contrast attribute.

Both maps are empty by default, so nothing is emitted until you set values. They take the same keys and values as the theme override maps below.

@use "@nuvoui/core" with (
$high-contrast-light: ("text-muted": #000, "border-control": gray 900),
$high-contrast-dark: ("text-muted": #fff)
);
<html data-contrast="high">
<html data-theme="dark" data-contrast="high">

Core does not read the prefers-contrast media query for tokens. Set data-contrast="high" from a user setting, or from prefers-contrast: more in your own script. $high-contrast-dark is ignored when $enable-dark-mode is false.

data-contrast is unrelated to the high-contrast mixin, which wraps styles in @media (forced-colors: active) for Windows high contrast and other forced colour modes.

$light-theme and $dark-theme change theme and role tokens. They hold only the tokens you change and are merged over the built-in defaults.

  • Keys are token names without --nu-: "bg-base", "brand", "text-link".
  • Values are any CSS value, or a scale reference.
@use "@nuvoui/core" with (
$brand: #1ab5a5,
$light-theme: (
"brand": brand 800,
"bg-base": #fff
),
$dark-theme: (
"bg-base": #121212
)
);

A scale reference is a role or palette name, a space, and a stop: brand 800, gray 50, blue 700. It is emitted as a reference to the palette token, so the value stays connected to the generated scale.

:root,
[data-theme="light"] {
--nu-brand: var(--nu-brand-800);
}

Write the reference unquoted, with a space. Only stops 50, 100 to 900 and 950 exist.

Value Result
brand 800 var(--nu-brand-800)
#fff, rgb(255 255 255) Used as written
brand800, brand 450 Error: malformed scale reference
orange 600 Error: orange is not a role or palette name

text-on-{role} is computed in each theme from that theme’s resolved {role} fill, in the light and the dark theme alike: core picks whichever of the theme’s text-regular and text-inverted has the higher WCAG contrast against the fill. With the defaults, the dark theme uses lighter fills, so every dark text-on-{role} is the dark text colour. When a theme map sets {role}, text-on-{role} is recomputed for the new fill. Set text-on-{role} in the same map to choose it yourself. When the computed colour reaches less than 4.5:1 on its fill, the compiler prints a warning that names the theme map to fix, for example text-on-brand ($dark-theme): the better of text-regular and text-inverted reaches 4.32:1 on the brand fill (#8f5eff), below 4.5:1. Point the fill at another stop or set text-on-brand in $dark-theme. The default configuration emits no warnings.

A key that is not a built-in token creates a new token. It must be in both $light-theme and $dark-theme, so the dark theme never falls back to a light value. A new bg-*, text-* or border-* colour token also gets a utility class and a mixin word.

@use "@nuvoui/core" with (
$generate-utility-classes: true,
$light-theme: ("bg-card": gray 50),
$dark-theme: ("bg-card": #202020)
);

This emits --nu-bg-card in both themes, the class .bg-card and the mixin form bg(card). A custom text-on-* token works the same way: "text-on-card" gives .text-on-card (with its hover: and group-hover: forms) and text-color(on-card). The built-in text-on-{role} tokens have the plain class only.

A new key must not reuse a palette, basic colour or scale token name (red-500, white, space-4): those names are reserved.

Input Error
$light-theme: ("brand-defualt": brand 800) $light-theme has unknown tokens: brand-defualt. Fix the typo, or add a custom token to both $light-theme and $dark-theme.
$dark-theme: ("bg-card": #202020) alone $dark-theme has unknown tokens: bg-card. Add a new token to both $light-theme and $dark-theme.
$light-theme: ("brand": brand800) 1 theme value problem(s). ... Light theme (defaults merged with $light-theme), token 'brand' (set in $light-theme) has a malformed scale reference (brand800). Write name and stop unquoted, separated by a space, e.g. brand 600. Valid stops: 50, 100, 200, 300, 400, 500, 600, 700, 800, 900, 950.
$light-theme: ("brand": orange 600) ... token 'brand' (set in $light-theme): orange 600 refers to 'orange', which is not a role or palette name. ...
$light-theme: ("error": red 700) $light-theme: 'error' cannot be set; error tokens follow danger; override danger-* instead.
$light-theme: ("brand-default": brand 800) $light-theme: 'brand-default' was renamed to 'brand' in 2.0 (--nu-brand). Rename the key.
$light-theme: ("error-default": red 700) $light-theme: 'error-default' was renamed to 'error' in 2.0, and error tokens follow danger; set 'danger' instead.
$light-theme: ("red-500": #f00) $light-theme: custom token 'red-500' is already a palette, basic colour or scale token (--nu-red-500). Choose another name.

The default value of every token in both themes is listed in the full token list.

A theme map can point the brand at a different scale in each theme. This site does it: the brand is orange in the dark theme and blue in the light theme. $brand sets the orange scale, which the dark theme uses through its default stops; the light theme map points every brand token at the blue palette instead.

@use "@nuvoui/core" with (
$generate-utility-classes: true,
$parent-selector: ".nuvoui",
$brand: #ff6f00,
$light-theme: (
"brand": blue 700,
"brand-hover": blue 800,
"brand-active": blue 900,
"brand-subtle": blue 50,
"brand-border": blue 200,
"text-brand": blue 700,
"text-link": blue 700,
"text-link-hover": blue 800,
"border-focus": blue 600,
"bg-selected": blue 50
)
);

Switch the site theme with the selector in the header to see the brand change.

bg-brand bg-brand-subtle text-brand A link
<div class="flex wrap gap-3 x-center">
<button type="button">Base button</button>
<span class="bg-brand text-on-brand px-3 py-1 rounded">bg-brand</span>
<span class="bg-brand-subtle text-brand px-3 py-1 rounded">bg-brand-subtle text-brand</span>
<a href="#brand-per-theme">A link</a>
</div>

Points to keep in mind:

  • Override the whole set. Brand appears in several tokens: the fill and its states, brand-subtle, brand-border, text-brand, the link colours, border-focus and bg-selected. Leave one out and it keeps the other scale.
  • text-on-brand follows. It is recomputed for the brand fill in each theme, so the button label stays readable on both fills.
  • Palette classes do not flip. bg-brand-500 is always shade 500 of the $brand scale (orange here). Use the role classes for brand colour that should follow the theme.
Mixin Output
dark-mode { ... } Inside a rule, pseudo-element rules included: applies where the dark tokens resolve, on and under [data-theme="dark"], in an inverse section that resolves dark, and under :root:not([data-theme]) when the operating system is dark, stopping at a nested data-theme section that resolves light
prefers-dark { ... } @media (prefers-color-scheme: dark): the operating system setting only, ignoring data-theme
dark-theme Emits the dark token declarations, for a custom scope
high-contrast { ... } @media (forced-colors: active)
@use "@nuvoui/core" as *;
.logo {
@include dark-mode {
filter: invert(1);
}
}
@media not all and (prefers-color-scheme: dark) {
@scope ([data-theme="dark"], [data-theme="inverse"]:not([data-theme="dark"] *, [data-theme="inverse"] *), [data-theme="dark"] [data-theme="inverse"] [data-theme="inverse"]) to ([data-theme="light"], [data-theme="dark"], [data-theme="inverse"]) {
.logo:scope, .logo:is(:scope *) {
filter: invert(1);
}
}
}
@media (prefers-color-scheme: dark) {
@scope ([data-theme="dark"], :root:not([data-theme]), :root:not([data-theme]) [data-theme="inverse"] [data-theme="inverse"], [data-theme="inverse"]:not([data-theme="dark"] *, [data-theme="inverse"] *, :root:not([data-theme]) *), [data-theme="dark"] [data-theme="inverse"] [data-theme="inverse"]:not(:root:not([data-theme]) *)) to ([data-theme="light"], [data-theme="dark"], [data-theme="inverse"]) {
.logo:scope, .logo:is(:scope *) {
filter: invert(1);
}
}
}

The output uses CSS @scope (Chrome 118, Safari 17.4, Firefox 146); a browser without @scope ignores the dark-mode styles. In a pseudo-element rule (.icon::before) the pseudo-element is kept at the end of each selector.

Prefer tokens over dark-mode: a value written with tokens adapts by itself. Keep dark-mode for changes that tokens cannot express, such as an image filter.

Variable Default Purpose
$light-theme () Light overrides and custom tokens
$dark-theme () Dark overrides and custom tokens
$high-contrast-light () High contrast overrides for light contexts
$high-contrast-dark () High contrast overrides for dark contexts
$enable-dark-mode true false removes the dark block, the operating system fallback, inverse sections and the dark high contrast block

The light block is scoped by $parent-selector for nested [data-theme="light"] elements (:root, .app [data-theme="light"]). The dark, inverse, fallback and high contrast blocks are global, so a theme attribute works anywhere on the page.

Theme tokens are emitted when the theme feature flag is on, whether or not utility classes are generated. With theme off, no tokens are emitted at all.