Sept 1 2023

A way to make dark-mode in React

react
next.js
typescript
theme
darkmode

How to create a dark mode for react.

Table of Contents

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:

  1. HTML code for button
  2. Icons for the button
  3. 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:

  1. get initial theme - getInitialTheme()
  2. toggle theme onClick - handleTheme()
  3. set document.body.dataset.theme to the new theme
  4. set new theme to localStorage for future visits and page reloads
  5. memo to stop unnecessary re-renders
  6. custom hook useTheme
  7. 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 called useTheme.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 called ThemeButton.test.tsx. Inside this test-suite we will run the following test. This test will ensure our component works as expected.

  • The tests are split into rendering and behaviour.

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!


View all