Dark Mode Toggle In React App

Written by Lucie Zdeňková on 2025-03-27

reactjavascriptcss

A few weeks ago, we published implementation of darkmode for static website without any javascript part 1, and part 2. This week, we would like to show you, how this useful feature can be implemented in any React application. Good news is, that coding such feature in React is way easier and requires less code!


App.js

Following code snippet shows the App() implemetation. Boolean status of mode preference is stored in state variable const [isDark, setIsDark] = useState(false);. We detect the initial dark mode preference by window.matchMedia("(prefers-color-scheme: dark)").matches and following if confition sets isDark to true if applicable. The method mq.addEventListener("change", (evt) => setIsDark(evt.matches)); listens to the user's preference changes and performs color scheme changes without reload. The empty dependency array ([]) ensures the effect runs only on mount.

Let's explain the JSX part, the data-bs-theme={isDark ? "dark" : "light"} attribute on a wrapper element defines changes accross whole app. The <Toggle /> component represents the visual knob, more details comes in next code snippet.

import React, { useState, useEffect } from "react";
import { Navbar, Container } from "react-bootstrap";

function App() {

    const [isDark, setIsDark] = useState(false);

    useEffect(() => {
        const mq = window.matchMedia("(prefers-color-scheme: dark)").matches;

        if (mq) {
            setIsDark(true);
        }

        mq.addEventListener("change", (evt) => setIsDark(evt.matches));
    }, []);

    return (
        <Container fluid className='App' data-bs-theme={isDark ? "dark" : "light"} >
            <Navbar>
                    <Toggle isChecked={isDark} handleChange={() => setIsDark(!isDark)} />
            </Navbar>
        </Container >
    )
}

The <Toggle /> component requires parametres handleChange for changing the state and isChecked for knowing the current state. The key feature is hidden input element with styled label element (see CSS part below). Input controls the behaviour and requires onChange and checked attributes. Used icons <IoSunny /> and <IoIosMoon /> are from React Icons. Different icon sets might be under different licenses, <IoSunny /> and <IoIosMoon /> belongs to io and io5 sets and are under MIT license.

export const Toggle = ({ handleChange, isChecked }) => {
  return (
    <span>
      <input
        type="checkbox"
        id="darkmode-toggle"
        onChange={handleChange}
        checked={isChecked}
      />
      <label htmlFor="darkmode-toggle" className="darkmode-label">
        <span className="sun"><IoSunny /></span>
        <span className="moon"><IoIosMoon /></span>
      </label>
    </span>
  );
};

style.css

UI of dark mode toggle for React app

Next part describes all necessary CSS code to implement visual toggle together with color schemes.

Following code defines the color schemes. Fist selector :root defines colors and other css properties for the light mode whereas selector [data-bs-theme="dark"] defines color and properties for dark mode. We are using so called CSS varibles, which allows to alter values for different selectors.

:root {
  --background-color: #f6f6f6;
  --primary-text-color: #0a0a0a;
  --gradient-bg1: #ffcc89;
  --gradient-bg2: #d8860b;
  --label-left: 2px;
}
[data-bs-theme="dark"] {
  --background-color: #0a0a0a;
  --primary-text-color: #f6f6f6;
  --gradient-bg1: #777;
  --gradient-bg2: #3a3a3a;
  --label-left: 98px;
  --transformantion: translateX(-100%);
}

As mentioned above, the principle of the solution is hidden input. This CSS code hides the input visually.

input#darkmode-toggle {
  width: 0;
  height: 0;
  visibility: hidden;
  display: none;
}

Following code is styling of the toggle and very much overlaps with css explanation described in Darkmode without javascript part 2.

This section defines the main visual container of the toggle, designed to resemble a rounded switch. The display: block property ensures it behaves as a block-level element, allowing precise control over its width and height. The position: relative is essential for correctly positioning the knob in the next step. The cursor: pointer signals interactivity, while border-radius and box-shadow shape the toggle with smooth edges and subtle depth.

label.darkmode-label {
  display: block;
  position: relative;
  width: 100px;
  height: 35px;
  border-radius: 40px;
  box-shadow: inset 0px 1px 3px rgba(0, 0, 0, 0.4), inset 0px -1px 3px rgba(255, 255, 255, 0.4);
  cursor: pointer;
  background-color: #242424;
}

The knob is designed to transition smoothly between light and dark modes. With position: absolute it is positioned relative to the toggle container defined in the previous code snippet. The content: "" property ensures the :after pseudo-element is rendered. Horizontal positioning is dynamically controlled using the CSS variable left: var(--label-left), while the knob’s background is set with a gradient using background: linear-gradient(180deg, var(--gradient-bg1), var(--gradient-bg2)). Movement animations are handled by transform: var(--transformation). Other properties like width, height, border-radius, and box-shadow follow the same styling principles as before. Finally, top: 2px ensures proper vertical alignment.

label.darkmode-label:after {
  content: "";
  width: 36px;
  height: 31px;
  position: absolute;
  top: 2px;
  left: var(--label-left);
  background: linear-gradient(180deg, var(--gradient-bg1), var(--gradient-bg2));
  border-radius: 36px;
  box-shadow: 0px 1px 2px rgba(0, 0, 0, 0.2);
  transform: var(--transformantion);
}

Next code snippet adds smooth animations to the toggle's movements and interactions. First property transition: 0.3s ensures a 0.3-second animation for the toggle changes. Property width: 52px with selector label.darkmode-label:active:after slightly enlarges the knob during interaction for a tactile feedback effect.

label.darkmode-label,
label.darkmode-label:after {
  transition: 0.3s
}

label.darkmode-label:active:after {
  width: 52px;
}

Icons styling is simple. The property display: block ensures it behaves like a block-level element, position: absolute places it relative to its parent .darkmode-label. transition: 0.3s enables a smooth transition when changing themes. z-index: 1 ensures it appears above other elements. Properties top and left defines vertical and horizontal positions.

label.darkmode-label span.sun {
  display: block;
  position: absolute;
  width: 24px;
  transition: 0.3s;
  z-index: 1;
  left: 11px;
  top: 3px;
}

label.darkmode-label span.moon {
  display: block;
  position: absolute;
  width: 24px;
  transition: 0.3s;
  z-index: 1;
  left: 72px;
  top: 3px;
}

In this post, we built a dark mode toggle in React that respects system preferences while allowing manual control. Using window.matchMedia("(prefers-color-scheme: dark)"), we initialized the theme state and listened for changes efficiently with useEffect. We also explained the CSS behind the toggle, including smooth transitions, dynamic positioning using CSS variables, and the placement of sun and moon icons for visual feedback. With this approach, the app seamlessly adapts to user preferences while maintaining a clean and interactive design.

Thank you for reading and see ya next time!