blog

How to implement Dark Theme using Local Storage

A practical guide for creating a light/dark theme toggle with JavaScript, including HTML setup, CSS styling, and user preference handling.

This guide outlines the technical implementation of a light/dark theme toggle for web applications.

HTML Structure

The toggle is implemented using a simple button element:

<button>
  <span class="theme-label light">Light</span>
  <span class="theme-label dark">Dark</span>
</button>

We have made a clickable component button in html, and added span elements inside it. The span elements adds theme-label, light and dark classes & have text Light and Dark to represent corresponding theme. This structure provides a foundation for applying styles and attaching event listeners.

JavaScript Implementation

The core functionality is implemented through JavaScript, utilizing DOM manipulation and event handling:

const button = document.querySelector("button");

/** Set the theme to dark/light mode. */
const setTheme = (dark) => {
  document.documentElement.classList[dark ? "add" : "remove"]("theme-dark");
};

// Toggle the theme when a user clicks the button.
button.addEventListener("click", () => setTheme(!isDark()));

// Initialize button state to reflect current theme.
setTheme(isDark());

function isDark() {
  return document.documentElement.classList.contains("theme-dark");
}
const button = document.querySelector("button");

This selects the button element from our html.

const setTheme = (dark) => {
  document.documentElement.classList[dark ? "add" : "remove"]("theme-dark");
};

This creates a function setTheme with a parameter dark which will be a truthy or falsy value. Accordingly, it adds or removes a class theme-dark to the root element or document.documentElement or html element of the page.

button.addEventListener("click", () => setTheme(!isDark()));

This creates an event listener for the button, which essentially says set the opposite theme to what it was presently after the click. We’ll talk about the isDark function further.

function isDark() {
  return document.documentElement.classList.contains("theme-dark");
}

Finally the function isDark as the name suggests, returns true if the page is in dark theme, else false. Internally, at the code level it is checking if our html has the class ‘theme-dark’ or not.

CSS Implementation

The visual representation of the theme is controlled through CSS, utilizing CSS custom properties (also known as CSS variables) for dynamic style switching:

We have added the event listener to our button for adding or removing class theme-dark from the html element but haven’t reflected a change on the page physically. To achieve this we have to use css styles conditionally on the theme-dark class. Our CSS file looks like this:

:root {
  --gray-999: #ffffff;
}

:root.theme-dark {
  --gray-999: #090b11;
}

body {
  background-color: var(--gray-999);
}

/* button styles */
.theme-label {
  padding: 0.5rem;
  width: 2rem;
  height: 2rem;
  font-size: 1rem;
}

root targets the html element of the page.

We have predefined the behaviour of the css variable :root, whose value will toggle based on when html tag has theme-dark class or not.

We are using custom css styles in it which are just colors keeping in mind what color scheme should be shown light vs dark. To show all this on the web page, all we need to do is apply the color ---gray-999 to the background color of our body. This makes the class logic we wrote in our JS file complete and visible in our page.

Enhanced Styling

Additionally, we need CSS implementation to provide visual feedback on the current theme state.

As of right now, if we click the button in our page it will toggle the theme of the page but there is no indicator to know which theme is currently applied. This creates a poor user interface experience.


The visual indication of the button's state is accomplished through CSS styling.

Updated css file will look like:

:root {
  --gray-0: #090b11;
  --gray-200: #3d4663;
  --gray-999: #ffffff;
  --accent-regular: #9fd3c7;
}

:root.theme-dark {
  --gray-0: #ffffff;
  --gray-200: #c3cadb;
  --gray-999: #090b11;
  --accent-regular: #9fd3c7;
}

body {
  background-color: var(--gray-999);
  color: var(--gray-200);
}

button {
  display: flex;
  padding: 0;
  cursor: pointer;
}

.theme-label {
  z-index: 1;
  position: relative;
  display: flex;
  padding: 0.5rem;
  width: 2rem;
  height: 2rem;
  font-size: 1rem;
}

.theme-label.light::before {
  content: "";
  z-index: -1;
  position: absolute;
  inset: 0;
  background-color: var(--accent-regular);
}

html.theme-dark .theme-label.light::before {
  transform: translateX(100%);
}

We’ve added some additional custom css variable colors and styled the button component and it’s inner elements. Let’s break them down

button {
  display: flex;
  padding: 0;
  cursor: pointer;
}

display: flex This makes the button element of flexible length.
We also want the button to have no padding, this removes any default padding being applied to the element. The cursor: pointer; CSS property changes the mouse cursor to a hand icon when hovering over an element, signaling the users that it’s clickable or interactive.

.theme-label {
  z-index: 1;
  position: relative;
  display: flex;
  padding: 0.5rem;
  width: 2rem;
  height: 2rem;
  font-size: 1rem;
}

Above code targets both the span elements inside the button to be styled.

z-index: 1 ensures the labels appear above other elements with lower z-index value where we’ll make another element further in the code.
position: relative establishes a positioning context for absolute positioning of child elements.
display: flex creates a flex container, allowing for easier positioning of the content
padding: 0.5rem adds internal spacing.
width: 2rem and height: 2rem set fixed dimensions, creating a square shape.
font-size: 1rem defines the text size within the label.

.theme-label.light::before {
  content: "";
  z-index: -1;
  position: absolute;
  inset: 0;
  background-color: var(--accent-regular);
}

This CSS block creates a pseudo-element for the light span element.

content: "" makes the pseudo element empty but visible if given any background color. z-index: -1 positions it behind the main content, so that we can see the span element text. position: absolute and inset: 0 expands the pseudo-element to occupy the full dimensions of its parent element. background-color: var(--accent-regular) sets its background color using the CSS variable defined in the :root classes.

By this we have shown a background color to the light text span, when the theme is in light mode. The opposite for the dark theme is incomplete.

html.theme-dark .theme-label.light::before {
  transform: translateX(100%);
}

This CSS rule applies a transformation to the pseudo-element (::before) of light span made in the previous code block only when the html element has the class theme-dark.

transform: translateX(100%) shifts the pseudo-element horizontally by 100% of its own width to the right.

Creating a visual toggle effect between light and dark themes. As the theme changes, the visible indicator switches between the light and dark positions on the button.

Lastly we’ll use localStorage to store user’s theme preference.

We don’t want to read the already stored theme preference after the html of page gets parsed and painted.

If we do so, then there would be a gap between when the painting finishes and when the script (optionally downloaded, if it an external script) gets parsed and executed. During this time the theme will be light by default.

We can only read if the preference is of dark-theme after the preference has been read from localStorage. Because of this there will be a flash of light-theme before the dark-theme gets applied (if the read preference was of dark-theme).

The code to read theme preference from local storage hence, should reside in an inline script in the head tag like so:

<head>
  <script>
    function onLoadThemeInit() {
      const getThemePreference = () => {
        if (typeof localStorage !== "undefined" && localStorage.getItem("theme")) {
          return localStorage.getItem("theme");
        }
        return window.matchMedia("(prefers-color-scheme: dark)").matches
          ? "dark"
          : "light";
      };
      const isDark = getThemePreference() === "dark";
      document.documentElement.classList[isDark ? "add" : "remove"]("theme-dark");

      if (typeof localStorage !== "undefined") {
        // Watch the document element and persist user preference when it changes.
        const observer = new MutationObserver(() => {
          const isDark = document.documentElement.classList.contains("theme-dark");
          localStorage.setItem("theme", isDark ? "dark" : "light");
        });
        observer.observe(document.documentElement, {
          attributes: true,
          attributeFilter: ["class"],
        });
      }
    }

    onLoadThemeInit();
  </script>
</head>

The getThemePreference() function: Checks if localStorage is available and contains a ‘theme’ item. If not, it falls back to the system preference using window.matchMedia().

const isDark = getThemePreference() === 'dark'; checks if the theme is dark through the function defined above & adds the class theme-dark to html through document.documentElement.classList[isDark ? 'add' : 'remove']('theme-dark');

We also have wrapped this code in a onLoadThemeInit function for better code organization & readability.

To end,

if (typeof localStorage !== "undefined") {
  // Watch the document element and persist user preference when it changes.
  const observer = new MutationObserver(() => {
    const isDark = document.documentElement.classList.contains("theme-dark");
    localStorage.setItem("theme", isDark ? "dark" : "light");
  });
  observer.observe(document.documentElement, {
    attributes: true,
    attributeFilter: ["class"],
  });
}

sets up a MutationObserver to watch for changes to the root element of the document or html. When the ‘theme-dark’ class is added or removed, it updates the theme preference in localStorage.

This build us a dark theme toggle button using localStorage where the theme will not loose it’s state on a refresh.

For testing: