Skip to main content
react-md

Dark Mode

There are a few different type of light/dark themes that can be available for a web application:

This page will guide you through all the approaches except for the "Light Only" theme since that is the default behavior. Check out the theme customization documentation for more theme behavior.

If the application should only use a dark theme, set the core.$color-scheme variable to dark when importing the react-md styles which will generate all styles with the dark theme variants.

@use "@react-md/core" with (
  $color-scheme: dark
);

@include core.styles;

Enabling the dark mode will change all the theme colors to their dark-mode variants and exclude any light theme behavior from the generated CSS. The default theme colors are:

NameColor
$dark-theme-background-color#121212
$dark-theme-surface-color#424242
$dark-theme-text-primary-colorrgba(255, 255, 255, 0.87)
$dark-theme-text-secondary-colorrgba(255, 255, 255, 0.6)
$dark-theme-text-hint-colorrgba(255, 255, 255, 0.38)
$dark-theme-text-disabled-colorrgba(255, 255, 255, 0.38)
$dark-surface-hover-background-colorrgba(255, 255, 255, 0.1)
$dark-surface-focus-background-colorrgba(255, 255, 255, 0.12)
$dark-surface-press-background-colorrgba(255, 255, 255, 0.24)
$dark-surface-selected-background-colorrgba(255, 255, 255, 0.12)
$dark-surface-ripple-background-colorrgba(255, 255, 255, 0.12)
$icon-dark-theme-color#B3B3B3

Dark Elevation Colors

When the dark mode is enabled, the surface background color will become lighter as the z-index/box-shadow increases to enable more contrast between temporary elements. Here is a list of components in react-md and their elevation:

NameElevation
AppBar2
Card2
Sheet (inline)2
StickyTableHeader4
Chip8
Toast6
Menu8
Dialog16
Sheet (normal)16

Configuring Elevation Colors

The different elevation colors can be changed by modifying the core.$dark-elevation-colors map. The next demo shows all 25 elevation colors and allows the values to be modified to see how they behave.

0
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import { Box } from "@react-md/core/box/Box";
import { cnb } from "cnbuilder";
import { type ReactElement } from "react";

import styles from "./DarkElevationColors.module.scss";

export default function DarkElevationColors(): ReactElement {
  return (
    <Box grid className={styles.container}>
      {Array.from({ length: 25 }, (_, i) => (
        <Box
          key={i}
          justify="center"
          className={cnb(i > 0 && styles[`elevation-${i}`])}
        >
          {i}
        </Box>
      ))}
    </Box>
  );
}

Press Enter to start editing.

@use "sass:map";
@use "@react-md/core" with (
  $color-scheme: dark,
  $dark-elevation-colors: (
    // this is really `$dark-theme-background-color`
    0: #121212,
    1: #1f1f1f,
    2: #242424,
    3: #262626,
    4: #282828,
    5: #282828,
    6: #2c2c2c,
    7: #2c2c2c,
    8: #2f2f2f,
    9: #2f2f2f,
    10: #2f2f2f,
    11: #2f2f2f,
    12: #333,
    13: #333,
    14: #333,
    15: #333,
    16: #353535,
    17: #353535,
    18: #353535,
    19: #353535,
    20: #353535,
    21: #353535,
    22: #353535,
    23: #353535,
    24: #383838,
  )
);

// NOTE: All the styles below are only required for this demo and should not be
// used in your code.
.container {
  @include core.use-dark-theme-elevation-colors;
}

@for $i from 1 through 24 {
  .elevation-#{$i} {
    @include core.box-shadow($i);

    // the real behavior is just to keep it empty
    @if not map.get(core.$dark-elevation-colors, $i) {
      @include core.set-dark-elevation-color($i, transparent);
    }
  }
}

Press Enter to start editing.

System Mode

If the application should use the dark theme only if the user has set their system preference to dark, set the core.$color-scheme to system. The generated styles will default to the light theme but add a media query to use the dark theme when the @media (prefers-color-scheme: dark) matches.

@use "@react-md/core" with (
  $color-scheme: system
);

@include core.styles;

Light or Dark Mode

If the application allows the user to select the current color scheme, generate the styles as normal with the default color scheme and create a global class name with the alternative theme using the core.use-light-theme or core.use-dark-theme mixins.

Once the styles are generated, the app should be wrapped in the LocalStorageColorSchemeProvider or a custom Cookie Storage Provider and apply the light or dark theme class name to the root html as needed. The following examples will use the LocalStorageColorSchemeProvider to keep it simple.

Start by configuring the default core.$color-scheme and creating a class name for the other color scheme. This example will default to a light theme and allow the user to configure it to be dark.

@use "@react-md/core" with (
  // If the $color-scheme is not set or set to `light`, the dark elevation
  // styles are omitted by default to keep the bundle smaller. So when enabling
  // a toggleable dark mode, force the styles to be created:
  $disable-dark-elevation: false
);

@include core.styles;

.dark-theme {
  @include core.dark-theme;
}

// if you want to default with a dark theme instead:
@use "@react-md/core" with (
  $color-scheme: dark
);

@include core.styles;

.light-theme {
  @include core.light-theme;
}

Wrap the app in the chosen ColorSchemeProvider implementation:

import { LocalStorageColorSchemeProvider } from "@react-md/core/theme/LocalStorageColorSchemeProvider";

function App() {
  return (
    <LocalStorageColorSchemeProvider>
      <RestOfTheApp />
      <ApplyTheme />
    </LocalStorageColorSchemeProvider>
  );
}

Change the styles based on the color scheme:

import { useColorScheme } from "@react-md/core/theme/useColorScheme";
import { useHtmlClassName } from "@react-md/core/useHtmlClassName";

function ApplyTheme() {
  const { currentColor, setColorScheme } = useColorScheme();
  useHtmlClassName(currentColor === "dark" ? "dark-theme" : "");

  // Whatever UI is desired for this
  return (
    <Button
      onClick={() =>
        setColorScheme((prev) => (prev === "light" ? "dark" : "light"))
      }
    >
      Theme
    </Button>
  );
}

Light or Dark or System Mode

The style setup will be about the same as the previous examples. Start by defining the default core.$color-scheme and create additional classes for the other color schemes.

@use "@react-md/core" with (
  $color-scheme: light
);

@include core.styles;

.dark-theme {
  @include core.dark-theme;
}

@media (prefers-color-scheme: dark) {
  .system-theme {
    @include core.dark-theme;
  }
}

Then apply the dark-theme or system-theme class name when needed:

import { useColorScheme } from "@react-md/core/theme/useColorScheme";
import { useHtmlClassName } from "@react-md/core/useHtmlClassName";
import { cnb } from "cnbuilder";

function ApplyTheme() {
  const { colorScheme } = useColorScheme();
  useHtmlClassName(cnb(colorScheme !== "light" && `${colorScheme}-theme`));
  return null;
}

This Website's Implementation

If a real-world example is useful, here's this website's implementation with next.js:

src/components/CookieColorSchemeProvider.tsx
"use client";

import { type ColorScheme } from "@react-md/core/theme/types";
import { ColorSchemeProvider } from "@react-md/core/theme/useColorScheme";
import { useColorSchemeProvider } from "@react-md/core/theme/useColorSchemeProvider";
import { type UseStateSetter } from "@react-md/core/types";
import {
  type ReactElement,
  type ReactNode,
  useCallback,
  useState,
} from "react";

import { COLOR_SCHEME_KEY } from "@/constants/cookies.js";
import { setCookie } from "@/utils/clientCookies.js";

export interface CookieColorSchemeProviderProps {
  children: ReactNode;
  defaultColorScheme: ColorScheme;
}

export function CookieColorSchemeProvider(
  props: CookieColorSchemeProviderProps
): ReactElement {
  const { children, defaultColorScheme } = props;

  const [colorScheme, setColorScheme] = useState(defaultColorScheme);
  const value = useColorSchemeProvider({
    colorScheme,
    setColorScheme: useCallback<UseStateSetter<ColorScheme>>((nextOrFn) => {
      setColorScheme((prev) => {
        const next = typeof nextOrFn === "function" ? nextOrFn(prev) : nextOrFn;

        setCookie(COLOR_SCHEME_KEY, next);

        return next;
      });
    }, []),
  });

  return <ColorSchemeProvider value={value}>{children}</ColorSchemeProvider>;
}
src/utils/clientCookies.ts
"use client";

import Cookies from "js-cookie";

export function setCookie(name: string, value: string): void {
  const today = new Date();
  const nextYear = today.getFullYear() + 1;
  Cookies.set(name, value, {
    secure: true,
    expires: new Date(today.setFullYear(nextYear)),
    // since Vercel is running on a different domain, this must be "none" instead
    // of strict to access it from the server
    sameSite: "none",
  });
}

export function removeCookie(name: string): void {
  Cookies.remove(name, {
    secure: true,
    sameSite: "none",
  });
}
src/components/RootLayout.tsx
import { CoreProviders } from "@react-md/core/CoreProviders";
import { RootHtml } from "@react-md/core/RootHtml";
import { MenuConfigurationProvider } from "@react-md/core/menu/MenuConfigurationProvider";
import { NullSuspense } from "@react-md/core/suspense/NullSuspense";
import { isColorScheme } from "@react-md/core/theme/isColorScheme";
import { cnb } from "cnbuilder";
import { Roboto_Flex } from "next/font/google";
import { cookies } from "next/headers.js";
import { type ReactElement, type ReactNode } from "react";

import { CookieColorSchemeProvider } from "@/components/CookieColorSchemeProvider.jsx";
import { LoadThemeStyles } from "@/components/LoadThemeStyles.jsx";
import { COLOR_SCHEME_KEY } from "@/constants/cookies.js";
import { rmdConfig } from "@/constants/rmdConfig.jsx";

import "./layout.scss";

export { metadata } from "@/constants/metadata.js";

const roboto = Roboto_Flex({
  subsets: ["latin"],
  display: "swap",
  variable: "--roboto",
});

export interface RootLayoutProps {
  children: ReactNode;
}

export function RootLayout({ children }: RootLayoutProps): ReactElement {
  const colorSchemeCookie = cookies().get(COLOR_SCHEME_KEY)?.value;
  const defaultColorScheme = isColorScheme(colorSchemeCookie)
    ? colorScheme
    : "system";

  return (
    <RootHtml className={cnb(roboto.variable, `${defaultColorScheme}-theme`)}>
      <CoreProviders {...rmdConfig}>
        <MenuConfigurationProvider renderAsSheet="phone">
          <CookieColorSchemeProvider defaultColorScheme={defaultColorScheme}>
            <NullSuspense>
              <LoadThemeStyles />
            </NullSuspense>
            <MainLayout>{children}</MainLayout>
          </CookieColorSchemeProvider>
        </MenuConfigurationProvider>
      </CoreProviders>
    </RootHtml>
  );
}
src/components/LoadThemeStyles.tsx
"use client";

import { useColorScheme } from "@react-md/core/theme/useColorScheme";
import { useHtmlClassName } from "@react-md/core/useHtmlClassName";

export function LoadThemeStyles(): null {
  const { colorScheme } = useColorScheme();
  useHtmlClassName(`${colorScheme}-theme`);
  return null;
}