Dark mode is a feature that users can't get enough of. It saves battery life, reduces eye strain, and minimizes blue light emissions. It's a simple feature that as a developer (all else equal), will set you apart far and wide from your competition. To boot, Material-UI supports dark/light themes out of the box, making it a great framework to build on. Despite this, due to dark mode's relative infancy in the web development world, there is a distinct lack of documentation and tutorials on how to actually code dark & light modes.
This article was originally published on Snappy Web Design
In this Material-UI tutorial, you'll learn
- How to use localStorage to save a user's theme preference
- How to use Material-UI to apply a dark theme and light theme
- How to use Gatsby's
gatsby-browser
andgatsby-ssr
to avoid css style conflicts on rehydration with server side rendering (SSR) - How to use a single Mui Theme file to serve both dark/light theme variants ("single source of truth")
- How to use React's
useReducer
,useContext
,createContext
, andContext.Provider
Why this Tutorial?
Although there are other tutorials on the web and the documentation for Material-UI is normally stout, you've probably found while researching tutorials on dark modes:
- Most tutorials show impractical / unorderly code that's difficult to reuse in your own project
- Material-UI's documentation falls short of demonstrating how to update the theme live - it only briefly touches on 'dark' and 'light' theme types
- Incomplete examples lead to Flashes of Unstyled Content (FOUC)
- Gatsby's Server Side Rendering (SSR) leads to FOUC
What's the finished product?
You can view the final code here:
...and here's how the final product will look and behave:
Project Structure
Before we dive into the code, let's first look at the project structure (which is available on CodeSandbox).
You'll notice it looks similar to a typical Gatsby.js project with the exception of the ThemeHandler.js
file.
ThemeHandler will...well, handle whether to display a light or dark theme. It'll contain our useContext and useReducer functions.
gatsby-browser wraps our application with our Context Provider. It allows our Gatsby site to have dynamic state.
gatsby-ssr serves the same purpose: wrapping our application with our Context Provider to make it accessible everywhere in our app. It prevents flashes of unstyled content with server-side rendering.
Layout is where we'll initially check the user's local storage to see if they have a previously set theme. If not, we'll set it to the default of our choosing. We'll wrap our application with our Theme using the Material-UI ThemeProvider.
Index does the least amount of work but the most important. It contains the button to toggle the dark/light theme and does so with an onClick function. This dispatches a function via our reducer to change the theme and sets the local storage to the user's newly-preferred theme.
Theme contains our:
1. Base theme, styles to be applied globally across both light and dark modes.
2. Dark theme, styles applied when dark mode is active, and lastly, our
3. Light theme, containing styles to be applied when the light mode is active.
If you're a visual learner, I hope that diagram gives you a mental picture of where we're headed.
Theme.js
One of the reasons why I think this approach is the best is because it has a single source of truth. Unlike other tutorials, we only use one theme, yet we provide multiple styles. We do it by nesting our themes: we define our global styles for both light and dark modes, and then spread that across our styles for our separate light and dark themes.
import { createMuiTheme } from "@material-ui/core/styles"
const baseTheme = createMuiTheme({
typography: {
fontFamily: "'Work Sans', sans-serif",
fontSize: 14,
fontFamilySecondary: "'Roboto Condensed', sans-serif"
}
})
const darkTheme = createMuiTheme({
...baseTheme,
palette: {
type: "dark",
primary: {
main: "#26a27b"
},
secondary: {
main: "#fafafa"
}
}
})
const lightTheme = createMuiTheme({
...baseTheme,
palette: {
type: "light",
primary: {
main: "#fafafa"
},
secondary: {
main: "#26a27b"
}
}
})
export { darkTheme, lightTheme }
Now our theme is set up for us to later import it like
import { darkTheme, lightTheme } from "./Theme"
Eventually, we'll make use of Material-UI's theme provider and pass in our theme dynamically:
<ThemeProvider theme={darkMode ? darkTheme : lightTheme}>
For now though, let's work on our ThemeHandler.
ThemeHandler.js
Our objective is simple: create a state value for darkMode
, set it false initially, and be able to access and update our state from anywhere within our Gatsby application.
For this, we make use of React's createContext, useReducer, and ContextProvider.
First up, we need to import createContext
and useReducer
, assign a variable as our action type which we'll use in our Reducer, and initialize our new Context:
import React, { createContext, useReducer } from "react"
let SET_THEME
export const darkModeContext = createContext()
Then, we'll create our useReducer function. Essentially, we'll be calling a function to set darkMode either true or false. The reducer is a switch statement to feed this value to our global state.
import React, { createContext, useReducer } from "react"
let SET_THEME
export const darkModeContext = createContext()
export const darkModeReducer = (state, action) => {
switch (action.type) {
case SET_THEME:
return {
...state,
darkMode: action.payload
}
default:
return state
}
}
Then, we'll create and export our DarkModeState function. We'll set our initial state (set dark mode to false on first load) in addition to initializing our dispatch function using the reducer we just created.
import React, { createContext, useReducer } from "react"
let SET_THEME
export const darkModeContext = createContext()
export const darkModeReducer = (state, action) => {
switch (action.type) {
case SET_THEME:
return {
...state,
darkMode: action.payload
}
default:
return state
}
}
export const DarkModeState = props => {
const initialState = {
darkMode: "false"
}
const [state, dispatch] = useReducer(darkModeReducer, initialState)
Lastly, we'll create our function (setDarkMode
) to update our state. It uses the dispatch function which feeds into our reducer's switch statement.
We return our darkModeContext.Provider
which makes both the darkMode state, and the setDarkMode function available globally across our app.
import React, { createContext, useReducer } from "react"
let SET_THEME
export const darkModeContext = createContext()
export const darkModeReducer = (state, action) => {
switch (action.type) {
case SET_THEME:
return {
...state,
darkMode: action.payload
}
default:
return state
}
}
export const DarkModeState = props => {
const initialState = {
darkMode: "false"
}
const [state, dispatch] = useReducer(darkModeReducer, initialState)
const setDarkMode = async bool => {
dispatch({
type: SET_THEME,
payload: bool
})
}
return (
<darkModeContext.Provider
value={{
darkMode: state.darkMode,
setDarkMode
}}
>
{props.children}
</darkModeContext.Provider>
)
}
🔧 Fixing Gatsby's Rehydration Issue
WARNING: Do not skip this step or you will waste hours of your life debugging. I wasted two days debugging flashes of unstyled content the first time I implemented dark mode - learn from my mistakes.
Because Gatsby builds pages long before they're rendered and served to the end-user's web browser, we have to take a couple additional steps when using dynamic state values.
If you want to read more about server-side rendering and Gatsby's webpack -- be my guest. In fact, you probably should read about Gatsby's Browser APIs. But for sake of brevity, let me sum it up like this:
You need to wrap every page with your React.useState component in Gatsby. Luckily, we can use Gatsby's built in API via the gatsby-browser.js
and gatsby-ssr.js
files. The syntax and content of the files are the exact same:
gatsby-browser.js
import React from "react"
import { DarkModeState } from "./src/components/UI/ThemeHandler"
export function wrapRootElement({ element, props }) {
return <DarkModeState {...props}>{element}</DarkModeState>
}
gatsby-ssr.js
import React from "react"
import { DarkModeState } from "./src/components/UI/ThemeHandler"
export function wrapRootElement({ element, props }) {
return <DarkModeState {...props}>{element}</DarkModeState>
}
Layout.js
We're almost to the end! The Layout provides our styles to the rest of our app via Material-UI's ThemeProvider.. Our approach (from a high-level) is:
- Import our light/dark themes
- Import our theme handler (
darkModeContext
) - Check the users localStorage to see if a preferred theme is already set in a
useEffect
function - If not, set the users preferred theme to the default (darkMode: false)
- Wrap our component with our dynamic theme (either light or dark) via the
ThemeProvider
Importantly, we need to also import and include the <CssBaseline />
component from Material-UI for the ThemeProvider to work.
The code for this is hardly worth elaborating on, so I'll let it speak for itself:
import React, { useContext, useEffect } from "react"
import CssBaseline from "@material-ui/core/CssBaseline"
import { ThemeProvider } from "@material-ui/core/styles"
import { darkTheme, lightTheme } from "./Theme"
import { darkModeContext } from "./ThemeHandler"
const Layout = ({ children }) => {
const DarkModeContext = useContext(darkModeContext)
const { darkMode, setDarkMode } = DarkModeContext
useEffect(() => {
const theme = localStorage.getItem("preferred-theme")
if (theme) {
const themePreference = localStorage.getItem("preferred-theme")
if (themePreference === "dark") {
setDarkMode(true)
} else {
setDarkMode(false)
}
} else {
localStorage.setItem("preferred-theme", "light")
setDarkMode(true)
}
}, [])
return (
<ThemeProvider theme={darkMode ? darkTheme : lightTheme}>
<CssBaseline />
<main>{children}</main>
</ThemeProvider>
)
}
Index.js (The final step!)
If you've made it this far, pat yourself on the back. This is the final (and simplest) step before you'll have a functioning dark mode toggle.
Let's not waste any more time.
- First, we need to wrap our Index Page with our Layout component.
- Then, we need to create a button to toggle the theme
- We need to create an onClick function for the button,
handleThemeChange
- Inside the function, we update
localStorage
andsetDarkMode
either true or false using our Context Provider:
import React, { useContext } from "react"
import Layout from "../components/UI/Layout"
import Button from "@material-ui/core/Button"
import { darkModeContext } from "../components/UI/ThemeHandler"
const IndexPage = () => {
const DarkModeContext = useContext(darkModeContext)
const { darkMode, setDarkMode } = DarkModeContext
const handleThemeChange = () => {
if (darkMode) {
localStorage.setItem("preferred-theme", "light")
setDarkMode(false)
} else {
localStorage.setItem("preferred-theme", "dark")
setDarkMode(true)
}
}
return (
<Layout>
<Button
variant="contained"
color="secondary"
size="medium"
onClick={handleThemeChange}
>
Toggle {darkMode ? "Light" : "Dark"} Theme
</Button>
</Layout>
)
}
export default IndexPage
Boom! Just like that, you have a toggleable dark/light mode with Gatsby and Material-UI.
Finished Product
Did you find this article helpful?
If you read this whole article, thank you. I hope you learned something valuable.
If you did, would you take a second to share the article by clicking below? It helps our cause immensely!
Make sure to also click the follow button to get notified when new posts go live 🔔
Top comments (0)