Skip to content
NuvoUI

Dark mode toggle

A toggle button that switches the page between light and dark themes, remembers the choice and follows the operating system until a choice is made.

Preview

Appearance

Follows your system setting until you choose.

In this preview the button switches this panel only and stores nothing. The script below switches the whole page.

With utility classes

The markup of the preview. Classes come from the prebuilt dist/nuvoui.min.css, or from your own build with $generate-utility-classes: true.

HTML
<div class="flex wrap between x-center gap-4 bg-surface text-regular border rounded-lg p-4">
    <div>
        <p class="font-semibold m-0">Appearance</p>
        <p class="text-sm text-muted m-0">Follows your system setting until you choose.</p>
    </div>
    <button class="bg-alternate text-regular border hover:bg-hover rounded-full text-sm" type="button" aria-pressed="false" data-theme-toggle>Dark mode</button>
</div>

With apply()

The same result with BEM class names and no utility classes. @use "@nuvoui/core" as *; emits the tokens and base styles, and each apply() writes one rule.

SCSS
@use "@nuvoui/core" as *;

.appearance {
    @include apply(
        flex, // display: flex
        wrap, // let items wrap onto new lines
        between, // main axis: space between (justify-content)
        x-center, // cross axis: center (align-items)
        gap(4), // gap: spacing key 4 (1rem)
        p(4), // padding: spacing key 4 (1rem)
        bg(surface), // surface background, follows the theme
        text-color(regular), // regular text color, follows the theme
        border(1), // 1px border (width key 1), regular color
        rounded(lg) // corner radius: size key lg (0.75rem)
    );
}

.appearance__title {
    @include apply(
        m(0), // margin: 0
        font(semibold) // font weight: semibold (600)
    );
}

.appearance__text {
    @include apply(
        m(0), // margin: 0
        font-size(sm), // font size key sm (0.875rem)
        text-color(muted) // muted text color, follows the theme
    );
}

.theme-toggle {
    @include apply(
        rounded(full), // fully rounded ends (pill shape)
        font-size(sm), // font size key sm (0.875rem)
        bg(alternate), // alternate background, follows the theme
        text-color(regular), // regular text color, follows the theme
        border(1), // 1px border (width key 1), regular color
        bg(hover) hover // hover background on hover
    );

    &[aria-pressed="true"] {
        @include apply(
            bg(selected), // selected item background
            text-color(brand), // brand text color
            border-color(brand) // border color: brand
        );
    }
}
HTML
<div class="appearance">
    <div>
        <p class="appearance__title">Appearance</p>
        <p class="appearance__text">Follows your system setting until you choose.</p>
    </div>
    <button class="theme-toggle" type="button" aria-pressed="false" data-theme-toggle>Dark mode</button>
</div>

Script

The first script runs in <head>, so a stored theme applies before the first paint. The second switches the theme and keeps aria-pressed in step with it. The playground blocks scripts; use its theme switch to see the dark tokens there.

HTML
<!-- In <head>: applies a stored choice before the first paint -->
<script>
    try {
        const theme = localStorage.getItem("theme");
        if (theme === "light" || theme === "dark") document.documentElement.dataset.theme = theme;
    } catch {}
</script>

<!-- At the end of <body> -->
<script>
    (() => {
        const button = document.querySelector("[data-theme-toggle]");
        const root = document.documentElement;
        const system = matchMedia("(prefers-color-scheme: dark)");
        const isDark = () => (root.dataset.theme ? root.dataset.theme === "dark" : system.matches);
        const sync = () => button.setAttribute("aria-pressed", String(isDark()));

        button.addEventListener("click", () => {
            const next = isDark() ? "light" : "dark";
            root.dataset.theme = next;
            try {
                localStorage.setItem("theme", next);
            } catch {}
            sync();
        });
        system.addEventListener("change", sync);
        sync();
    })();
</script>

Tokens used

Every color comes from a theme token, so the recipe follows the light, dark and inverse themes with no extra CSS.

data-theme
Set on the html element, it selects the light or dark token block. Without the attribute, NuvoUI follows prefers-color-scheme, so a first visit matches the operating system.
bg-surface, text-regular, text-muted
The panel and its text; they flip with the theme.
bg-alternate, bg-hover, border-regular
The button at rest and on hover.
bg-selected, text-brand, brand
The pressed state in the SCSS version, matched on aria-pressed="true".
border-focus
The focus ring the base layer draws on every button.