A way to make dark-mode in React
How to create a dark mode for react.
Table of Contents
- Introduction
- Installation & setup
- Css code
- Creating the component
<ThemeButton/>
- Function and behavior
- Unit testing
- Conclusion
Introduction
By the end of this blog you will know how to implement a dark theme for react with typescript that is persistent and applicable across multiple frameworks such as vite.js and next.js with react.
For this blog I will be using typescript.
Features:
-
dark/light mode
-
no react context
-
persistent across page reloads and page revisits
-
typesafe
-
one component a
<ThemeButton/>
to input into an application & optional useTheme react hook to separate and refactor the code.
Installation & setup
For this build I am using Next.Js 13 with typescript. I have also tried this dark mode solution on Vite.Js and it works the same there. I will be using Tailwind CSS for some styles to the button component that toggles the theme, with css modules. This has no effect on the operation of the dark/light mode. I am also using react-icons
for the sun and moon button icons. This also has no effect on the operation of the theme.
Css code
First create a .css file to put the css code. I am calling it globals.css. Below is the css code for the themes. The colors are from the Tailwind CSS slate palette. This will give the colors for the text and backgrounds. The data attribute is used to apply the theme.
The data-theme attribute will be applied to the document body.
/* globals.css */
[data-theme="light"],[data-color="light"]{
--bg-1:#FFFFFF;
--bg-2:#f8fafc;
--bg-3:#f1f5f9;
--bg-4:#e2e8f0;
--bg-5:#cbd5e1;
--t-1:#020617;
--t-2:#1e293b;
--t-3:#334155;
--t-4:#475569;
--t-5:#64748b;
}
[data-theme='dark'],[data-color="dark"] {
--bg-1:#020617;
--bg-2:#1e293b;
--bg-3:#334155;
--bg-4:#475569;
--bg-5:#64748b;
--t-1:#ffffff;
--t-2:#f8fafc;
--t-3:#f1f5f9;
--t-4:#e2e8f0;
--t-5:#cbd5e1;
}
Creating the component <ThemeButton/>
Structure & rendering
Tasks:
- HTML code for button
- Icons for the button
- CSS code for the button
The component required is the button to toggle the theme. It will also set the initial theme when the component first renders on the page.
- Make a folder called ThemeButton
- Inside make a file called ThemeButton.tsx and inside put the code (below).
//ThemeButton.tsx
import {FiSun,FiMoon} from "react-icons/fi"
import useTheme from "./useTheme"
import styles from "./ThemeButton.module.css"
//more code here
const ThemeButton = () => {
// custom hook to set the theme to "light" || "dark"
const [theme,handleTheme]=useTheme(getInitialTheme)
return (
<button
aria-label="theme"
className={styles.theme}
onClick={handleTheme}>
{theme==="light"? <FiSun/>:<FiMoon/>}
</button>
)
}
export default ThemeButton
- Make a file called ThemeButton.module.css and inside put the code (below). Note this is just to style the button component.
/* ThemeButton.module.css */
.theme {
--transition-1:all 0.25s ease-in-out 0s
transition: var(--transition-1);
@apply flex items-center p-3 rounded font-semibold text-sm
}
.theme:hover {
/* custom background color from the globals.css*/
background-color: var(--bg-3);
}
Function and behavior
Tasks:
- get initial theme - getInitialTheme()
- toggle theme onClick - handleTheme()
- set document.body.dataset.theme to the new theme
- set new theme to localStorage for future visits and page reloads
- memo to stop unnecessary re-renders
- custom hook useTheme
- test the component in Jest and React Testing Library
Get the initial theme (getInitialTheme)
- This function runs once on the components initial render. Its code can be written in a separate file or outside of the scope of the
ThemeButton
component (for performance and clarity). Make a file called"getInitialTheme.tsx"
and put the code below inside:
//getInitialTheme.tsx
// type definition for typescript
type ThemeType = "light"|"dark"
function getInitialTheme():ThemeType {
let initialTheme:any = "light"
try{
//first check local storage for a previous theme value
initialTheme = localStorage.getItem('data-theme')
}catch(e){
//handle any errors...
}
if (initialTheme){
//return value from local storage and set initial theme for fcp
return initialTheme
}
//next check if user has a default system preference
let match = "(prefers-color-scheme: dark)"
if (window.matchMedia(match).matches){
return "dark"
}
// if no system preference "dark" or no local storage value return light
return "light"
}
export {getInitialTheme}
Custom useTheme hook
- now to make the
useTheme
hook to update the theme create a new file calleduseTheme.tsx
. (See below)
//useTheme.tsx
import {useEffect,useState} from "react"
import { getInitialTheme } from "./getInitialTheme"
//define types for typescript
type ThemeType = "light"| "dark"
type getInitialThemeType = ()=>"light"|"dark"
type useThemeType = [ThemeType,handleThemeType]
type handleThemeType = React.MouseEventHandler<HTMLButtonElement>
const useTheme = (getInitialTheme:getInitialThemeType):useThemeType=>{
const [theme,setTheme]=useState<ThemeType>(getInitialTheme)
const handleTheme:handleThemeType = ()=>{
setTheme(t=>t==="dark"?"light":"dark")
}
useEffect(()=>{
localStorage.setItem('data-theme',theme)
document.documentElement.dataset.theme=theme
},[theme])
return [theme,handleTheme]
}
export default useTheme
Memoization
- to stop unnecessary re-renders add
react memo
.
//ThemeButton.tsx
//more code above
export default memo(ThemeButton)
Unit testing
Now for testing our component using jest
and react-testing-library
-
after installing jest and react-testing-library and setting up for
typescript
, make a file calledThemeButton.test.tsx.
Inside thistest-suite
we will run the followingtest
. This test will ensure our component works as expected. -
The tests are split into
rendering
andbehaviour
.
import ThemeButton from "@/Theme/ThemeButton"
import { render,screen } from "@testing-library/react"
import user from "@testing-library/user-event"
import { getInitialTheme } from "@/Theme/getInitialTheme"
jest.mock("../Theme/getInitialTheme",()=>({
getInitialTheme:jest.fn(()=>"light")
}))
beforeEach(()=>{
jest.clearAllMocks()
})
function renderButton(){
render(<ThemeButton/>)
const button = screen.getByRole('button')
return {button}
}
describe("theme tests",()=>{
describe("rendering",()=>{
//it should render a button
//it should render
it("should render the theme button",()=>{
const {button} = renderButton()
expect(button).toBeInTheDocument()
})
it("should set the inital theme to light on the document.body",()=>{
const {button} = renderButton()
expect(document.documentElement.dataset.theme).toBe("light");
})
})
describe('behaviour',()=>{
//it should update ui on click
//it should update the body attribue onclick
it('should update the body attribue onClick',async()=>{
user.setup()
const {button} = renderButton()
await user.click(button)
expect(document.documentElement.dataset.theme).toBe("dark")
})
})
})
Conclusion
And that's it! Thank you for reading
. Feel free to reach out to me if you spot anything wrong or want any help with understanding this! github, linkedin
You have reached the end of this post. Thank you for reading!