</>Jonathan Harrell

Main Menu

Site Tools

System-Based Theming with Styled Components

Most operating systems these days support a system-wide light/dark mode toggle, and we may want our web sites and applications to take on a corresponding mode, essentially inheriting their color scheme from the system on which they are being viewed. Let’s take a look at how to implement this, and how it would integrate with a CSS-in-JS theming system such as Styled Components.

Detecting a user’s system theme involves either using the prefers-color-scheme media query in CSS, or checking the media feature in JavaScript. Browser support for these features is fairly good.

Basic System Theming

Link to this section

Let’s look at a basic implementation where we swap the theme object passed to Styled Component’s ThemeProvider component based on the detected system theme.

import { useState, useEffect } from "react";
const { ThemeProvider } from "styled-components";

// set up theme colors
const themes = {
  light: {
    colors: {
      text: "black",
      background: "white"
    }
  },
  dark: {
    colors: {
      text: "white",
      background: "black"
    }
  }
};

// set up styled components to use theme colors
const Wrap = styled.div`
  background-color: ${({ theme }) => theme.colors.background};
`;

const Heading = styled.h1`
  color: ${({ theme }) => theme.colors.text};
`;

const P = styled.p`
  color: ${({ theme }) => theme.colors.text};
`;

// set up detection of system dark mode
const darkModeQuery = window.matchMedia("(prefers-color-scheme: dark)");

const App = () => {
  // perform an initial detection of system theme
  const [theme, setTheme] = useState(darkModeQuery.matches ? "dark" : "light");

  // set up a listener to respond to future changes of system theme
  useEffect(() => {
    darkModeQuery.addListener((event) => {
      setTheme(event.matches ? "dark" : "light");
    });
  });

  // pass the correct theme object to the provider
  return (
    <ThemeProvider theme={themes[theme]}>
      <Wrap>
        <Heading>System Theming</Heading>
        <P>Change your system theme to see this page respond</P>
      </Wrap>
    </ThemeProvider>
  );
};

export default App;

This approach is straightforward and feels very natural to Styled Components. However, if our app is server-side rendered but a user has JavaScript disabled, the app will not respond to system theme changes.

Go to experiment

System Theming with Styled Components

Click here to view the experiment on Codepen

Supporting Disabled JavaScript with CSS Variables

Link to this section

We can enable our app to respond to the system theme even without JavaScript enabled (in cases where the app is server-side rendered) by taking a slightly different approach. Instead of storing our light and dark theme as separate styled component themes, we will use CSS variables, and override them with a media query.

import { useState, useEffect } from "react";
import { createGlobalStyle, ThemeProvider, css } from "styled-components";

// set up color values that will be used across both light and dark themes
const theme = {
  colors: {
    black: "black",
    white: "white"
  }
};

// set up light theme CSS variables
const lightValues = css`
  --text: ${({ theme }) => theme.colors.black};
  --background: ${({ theme }) => theme.colors.white};
`;

// set up dark theme CSS variables
const darkValues = css`
  --text: ${({ theme }) => theme.colors.white};
  --background: ${({ theme }) => theme.colors.black};
`;

const GlobalStyle = createGlobalStyle`
  :root {
    // define light theme values as the defaults within the root selector
    ${lightValues}

    // override with dark theme values within media query
    @media (prefers-color-scheme: dark) {
      ${darkValues}
    }
  }
`;

// set up styled components to use CSS variables
const Wrap = styled.div`
  background-color: var(--background);
`;

const Heading = styled.h1`
  color: var(--text);
`;

const P = styled.p`
  color: var(--text);
`;

const App = () => (
  <ThemeProvider theme={theme}>
    <>
      <GlobalStyle />
      <Wrap>
        <Heading>System Theming</Heading>
        <P>Change your system theme to see this page respond</P>
      </Wrap>
    </>
  </ThemeProvider>
);

export default App;

Now, when server-side rendered, our theme will change even without JavaScript enabled, since a CSS media query is now controlling the theme values.

Go to experiment

System Theming with Styled Components & CSS Variables

Click here to view the experiment on Codepen

Custom Theme Overrides

Link to this section

There are some cases where you may want to allow a user to manually change the theme, overriding what the app inherited from the system by default. Let’s look at how we might achieve this using our initial simpler example. We will need to add a theme toggle method that will update our theme state.

import { useState, useEffect } from "react";
import { ThemeProvider } from "styled-components";

// ...set up themes and styled components

// set up detection of system dark mode
const darkModeQuery = window.matchMedia("(prefers-color-scheme: dark)");

const App = () => {
  // perform an initial detection of system theme
  const [activeTheme, setActiveTheme] = useState(darkModeQuery.matches ? "dark" : "light");

  // set up a listener to respond to future changes of system theme
  useEffect(() => {
    darkModeQuery.addListener((event) => {
      setActiveTheme(event.matches ? "dark" : "light");
    });
  });

  // set up a theme toggle method that will update our state when a user clicks the button
  const toggleTheme = () => {
    setActiveTheme(activeTheme === "dark" ? "light" : "dark")
  }

  // pass the correct theme object to the provider
  return (
    <ThemeProvider theme={themes[activeTheme]}>
      <Wrap>
        <Heading>System Theming</Heading>
        <Button onClick={toggleTheme}>
          Change theme to {activeTheme === "light" ? "dark" : "light"}
        </Button>
      </Wrap>
    </ThemeProvider>
  );
};

export default App;
Go to experiment

System Theming with Styled Components & Custom Override

Click here to view the experiment on Codepen

Persistent Custom Theme Overrides

Link to this section

What if we want the user’s custom theme override to be persisted between sessions? We’ll need to bring in some logic to interact with localStorage in order to set and retrieve the user’s preference.

Let’s look at a full example that uses CSS variables, supports custom overrides, and persists those overrides between sessions:

import { useState, useEffect } from "react";
import { createGlobalStyle, ThemeProvider, css } from "styled-components";

// set up color values that will be used across both light and dark themes
const theme = {
  colors: {
    black: "black",
    white: "white"
  }
};

// set up light theme CSS variables
const lightValues = css`
  --text: ${({ theme }) => theme.colors.black};
  --background: ${({ theme }) => theme.colors.white};
`;

// set up dark theme CSS variables
const darkValues = css`
  --text: ${({ theme }) => theme.colors.white};
  --background: ${({ theme }) => theme.colors.black};
`;

const GlobalStyle = createGlobalStyle`
  :root {
    // define light theme values as the defaults within the root selector
    ${lightValues}

    // override with dark theme values if theme data attribute is set to dark
    [data-theme="dark"] {
      ${darkValues}
    }

    // support no JavaScript scenario by using media query
    &.no-js {
      @media (prefers-color-scheme: dark) {
        ${darkValues}
      }
    }
  }
`;

// set up styled components to use CSS variables
const Wrap = styled.div`background-color: var(--background);`;

const Heading = styled.h1`
  color: var(--text);
`;

const P = styled.p`
  color: var(--text);
`;

// set up detection of system dark mode
const darkModeQuery = window.matchMedia("(prefers-color-scheme: dark)");

// find saved theme
const savedTheme = localStorage.getItem("theme");

const App = () => {
  // set active theme to saved theme, if there is one
  // otherwise, set to system theme
  const [activeTheme, setActiveTheme] = useState(
    savedTheme ? savedTheme : darkModeQuery.matches ? "dark" : "light"
  );

  // set up a listener to respond to future changes of system theme
  useEffect(() => {
    darkModeQuery.addListener((event) => {
      setActiveTheme(event.matches ? "dark" : "light");
    });
  }, []);

  // every time the active theme changes, set the theme data attribute
  useEffect(() => {
    document.body.setAttribute("data-theme", activeTheme);
  }, [activeTheme]);

  // set up a theme toggle method that will update our state when a user clicks the button and save the new theme in localStorage
  const toggleTheme = () => {
    const newTheme = activeTheme === "light" ? "dark" : "light";
    setActiveTheme(newTheme);
    localStorage.setItem("theme", newTheme);
  };

  return (
    <ThemeProvider theme={theme}>
      <>
        <GlobalStyle />
        <Wrap>
          <Heading>System Theming</Heading>
          <Button onClick={toggleTheme}>
            Change theme to {activeTheme === "light" ? "dark" : "light"}
          </Button>
          <P>Refresh to see the persisted preference</P>
        </Wrap>
      </>
    </ThemeProvider>
  );
};

Now instead of using a media query to override CSS variable values, we use a data attribute set on the document body. This allows theme overrides to work.

Note that we support responding to system theme changes without JavaScript by using a no-js class and falling back to a media query. Of course, this won’t support custom overrides of the theme.

Go to experiment

System Theming with Styled Components & Persistent Custom Override

Click here to view the experiment on Codepen

Preventing the Default Theme Flash

Link to this section

There is one remaining problem with this implementation when used with server-side rendering. Since we are no longer using a media query, but rather a data attribute to set the theme, we get an initial default themed flash of content, before our app has a chance to actually detect and set the theme. We will need to relocate our theme-setting code to the head of the document, before the HTML has actually rendered.

We can store the theme and the theme toggle method on the window so that our React app will then have access to it. This code looks a bit messier, but it supports all of our use cases and prevents the annoying default themed flash.

In head:

(function () {
  // set up method to set theme value on window, set data attribute, and dispatch a custom event indicating the theme change
  function setTheme(newTheme) {
    window.__theme = newTheme;
    preferredTheme = newTheme;
    document.body.setAttribute("data-theme", newTheme);
    window.dispatchEvent(
      new CustomEvent("themeChange", {
        detail: newTheme
      })
    );
  }

  // grab the saved theme
  var preferredTheme = localStorage.getItem("theme");

  // set up method to set the active theme to the user’s preferred theme and save that theme for persistence
  window.__setPreferredTheme = function (newTheme) {
    setTheme(newTheme);
    localStorage.setItem("theme", newTheme);
  };

  // set up detection of system dark mode
  var darkModeQuery = window.matchMedia("(prefers-color-scheme: dark)");

  // set up a listener to respond to future changes of system theme
  darkModeQuery.addListener(function (event) {
    window.__setPreferredTheme(event.matches ? "dark" : "light");
  });

  // set active theme to saved theme, if there is one, or the system theme
  setTheme(preferredTheme || (darkModeQuery.matches ? "dark" : "light"));
})();

In our app:

import { useState, useEffect } from "react";

// ...set up CSS variables and styled components

const App = () => {
  const [activeTheme, setActiveTheme] = useState();

  useEffect(() => {
    // set initial theme based on value set on window
    setActiveTheme(window.__theme);

    // set up listener for custom theme change event
    window.addEventListener("themeChange", (event) => {
      setActiveTheme(event.detail);
    });
  }, []);

  // allow user to manually change their theme
  const toggleTheme = (theme) => {
    window.__setPreferredTheme(theme);
  };

  // ...render content
});

export default App;

Thanks to improving browser support for the detection of system themes, it has never been easier to implement a theme for your application or website that responds to your users’ operating system themes. With a bit of extra code, you can also enable user-chosen theme overrides. This will go a long way in creating a more customizable and comfortable experience for your users.

More Articles

Go to article

Implicit State Sharing in React & Vue

Learn to use React’s Context API and provide/inject in Vue to share state between related components without resorting to a global data store.

Go to article

Component Reusability in React & Vue

Learn how to use render props in React and scoped slots in Vue to create components that are flexible and reusable.

Go to article

What’s the Deal with Margin Collapse?

Learn about margin collapse, a fundamental concept of CSS layout. See visual examples of when margin collapse happens, and when it doesn’t.

Go to article

Better Typography with Font Variants

Learn how to use font variants, including ligatures, caps, numerals and alternate glyphs, to create more precise, beautiful typography on the web.