As we move towards a better and more accessible user experience on the web with every passing day, dark mode has become a mainstream feature for web apps. When it comes to the development of dark mode, it’s more than just adding a simple toggle button and managing the CSS variable. Here we will discuss creating a complete dark mode experience in React app.
Here is what we will cover:
Using system settings
Managing themes using CSS variables
Implementing the color scheme toggle using react-toggle
Storing user-preferred mode using use-persisted-state
Selecting color combination suited for a wider audience
Handling images in dark mode
You can find the demo application and its code on Github.
No one wants to hurt a user’s eyes when they land on their website! It’s best practice to set the app’s theme according to the device’s settings. CSS media queries, generally known for usage with responsive design, also help us check for other device characteristics.
Here we will use the prefers-color-scheme that gives us dark, light, or no-preference based on the device’s selected color scheme.
Even in its simplest form, this alone can help us adding a dark mode to web apps:
@media (prefers-color-scheme: dark) {
background-color: #1F2023
color: #DADADA
}
Like any other media query, styles in this block will be applied when the device’s color scheme is set to dark. Placing it in some component styles will look like this:
import { styled } from ‘@linaria/react’;
const Text = styled.p`
margin: 12px;
color: #1F2023;
background-color: #FAFAFA;
@media (prefers-color-scheme: dark) {
background-color: #1F2023
color: #DADADA
}
`;
This is good to begin with, but one cannot keep adding these styles in each component. In this case, CSS variables are the answer.
CSS variables are one tool that was missing from web styling for a long, long time. Now that they are available with all browsers, CSS is more fun and less of a pain.
CSS variables are scoped to the element(s) on which they are declared and participate in the cascade (i.e., elements are override values for children).
We can leverage CSS variables to define themes of our application. Here’s a small snippet to recall how CSS variables are declared:
body {
–color-background: #FAFAFA;
–color-foreground: #1F2023;
}
To use these variables in our components, we will swap color codes with variables:
const Text = styled.p`
margin: 12px;
color: var(–color-foreground);
background-color: var(–color-background);
`;
Now that our colors are defined via CSS variable we can change values on top of our HTML tree (e.g., <body>) and the reflection can be seen on all elements:
body {
–color-background: #FAFAFA;
–color-foreground: #1F2023;
@media (prefers-color-scheme: dark) {
–color-background: #1F2023;
–color-foreground: #EFEFEF;
}
}
At this point, we have the simplest solution that works based on the device’s preferences. Now we have to scale it for devices that do not natively support dark mode.
In this case, we have to make it easy for users to set their preferences for our web app. I opted for react-toggle to make our solution better at a11y along with good aesthetics. This can be achieved via simple button and useState.
Here is how our toggle component looks:
import React, { useState } from “react”;
import Toggle from “react-toggle”;
export const DarkModeToggle: React.FC = () => {
const [isDark, setIsDark] = useState<boolean>(true);
return (
<Toggle
className=”dark-mode-toggle”
checked={isDark}
onChange={({ target }) => setIsDark(target.checked)}
icons={{ checked: “🌙”, unchecked: “🔆” }}
aria-label=”Dark mode toggle”
/>
);
};
This component will hold the user’s selected mode, but what about the default value? Our CSS solution respected the device’s preference. To pull media query results in our react component, we will leverage react-responsive. Under the hood, it uses Window.matchMedia() and re-renders our component when the query’s output is changed.
An updated version of the button looks like the following:
import React, { useState } from “react”;
import Toggle from “react-toggle”;
export const DarkModeToggle: React.FC = () => {
const [isDark, setIsDark] = useState<boolean>(true);
const systemPrefersDark = useMediaQuery(
{
query: ‘(prefers-color-scheme: dark)’,
},
undefined,
(isSystemDark: boolean) => setIsDark(isSystemDark)
);
return (
<Toggle
className=”dark-mode-toggle”
checked={isDark}
onChange={({ target }) => setIsDark(target.checked)}
icons={{ checked: “🌙”, unchecked: “🔆” }}
aria-label=”Dark mode toggle”
/>
);
};
The useMediaQuery hook takes a query, initial value, and an onChange handler that is fired whenever the query’s output is changed.
Now our component will be in sync with the device’s preferences, and its value will be updated accordingly. But how can we test if it’s done right?
Thanks to developer-friendly browsers, we can emulate device preferences from browser inspectors; here is how it looks in Firefox:
It’s time to connect our toggle component’s state change to CSS. This can be done with several different techniques. Here, we have opted for the simplest one: adding a class on the root HTML tag and letting CSS variables do the rest.
To accommodate this, we will update the CSS of our body tag:
body {
–color-background: #FAFAFA;
–color-foreground: #1F2023;
&.dark {
–color-background: #1F2023;
–color-foreground: #EFEFEF;
}
}
Here is our effect to add and remove classes based on state:
…
useEffect(() => {
if (isDark) {
document.body.classList.add(‘dark’);
} else {
document.body.classList.remove(‘dark’);
}
}, [isDark]);
…
If we keep the user’s preferred color scheme in the component’s state, it might become problematic, because we won’t be able to get the values outside of this component. Also, it will vanish as soon as our app is mounted again. Both problems can be solved in different ways, including with React Context or any other state management approach.
One other solution is to use the use-persisted-state. This will help us fulfill all requirements. It persists the state with localStorage and keeps the state in sync when the app is open in different tabs of a browser.
We can now move our dark mode state in a custom hook that encapsulates all logic related to media query and persistent state. Here is how this hook should look:
import { useEffect, useMemo } from ‘react’;
import { useMediaQuery } from ‘react-responsive’;
import createPersistedState from ‘use-persisted-state’;
const useColorSchemeState = createPersistedState(‘colorScheme’);
export function useColorScheme(): {
isDark: boolean;
setIsDark: (value: boolean) => void;
} {
const systemPrefersDark = useMediaQuery(
{
query: ‘(prefers-color-scheme: dark)’,
},
undefined,
);
const [isDark, setIsDark] = useColorSchemeState<boolean>();
const value = useMemo(() => isDark === undefined ? !!systemPrefersDark : isDark,
[isDark, systemPrefersDark])
useEffect(() => {
if (value) {
document.body.classList.add(‘dark’);
} else {
document.body.classList.remove(‘dark’);
}
}, [value]);
return {
isDark: value,
setIsDark,
};
}
The toggle button component will be much simpler now:
/**
*
* ColorSchemeToggle
*
*/
import Toggle from ‘react-toggle’;
import { useColorScheme } from ‘platform/ColorScheme’;
import { DarkToggle } from ‘./Styled’;
const ColorSchemeToggle: React.FC = () => {
const { value, setValue } = useColorScheme();
return (
<DarkToggle>
<Toggle
checked={value === ‘dark’}
onChange={(event) => setValue(event.target.checked ? ‘dark’ : ‘light’)}
icons={{ checked: ‘🌙’, unchecked: ‘🔆’ }}
aria-label=”Dark mode”
/>
</DarkToggle>
);
};
export default ColorSchemeToggle;
While dark mode itself can be considered an accessibility feature, we should focus on keeping this feature accessible for a wider audience.
We leveraged react-toggle in our demo to ensure the button used for changing color scheme follows all a11y standards. Another important part is the selection of background and foreground colors in both dark and light mode. In my opinion, colors.review is a great tool to test the contrast ratio between colors; having a AAA grade makes our apps easier to navigate and more comfortable to look at.
For better aesthetics, we usually have pages with bright images. In dark mode, bright images might become a discomfort for users.
Several techniques can avoid these issues, including using different images for both modes and changing colors in SVG images. One way is to use CSS filters on all image elements; this will help lower eye strain when bright images appear on the user’s canvas.
To enable this, our global styles will look like the following:
body {
–color-background: #FAFAFA;
–color-foreground: #1F2023;
–image-grayscale: 0;
–image-opacity: 100%;
&.dark {
–color-background: #1F2023;
–color-foreground: #EFEFEF;
–image-grayscale: 50%;
–image-opacity: 90%;
}
}
img,
video {
filter: grayscale(var(–image-grayscale)) opacity(var(–image-opacity));
}
Accessibility in web apps today is not just a utility. Rather, it is one of the basic requirements. Dark mode in this respect, when implemented, should be considered as a full feature that requires much attention, just like any other critical feature.
In this article, we established a way to implement dark mode to its full extent; let me know in the comments if you feel I have missed anything.
The post Dark mode in React: An in-depth guide appeared first on LogRocket Blog.