I recently decided to add dark and light mode to my website so that website visitors can easily switch to an eye-friendly design whenever they want.
Why dark mode?
Dark and light mode can provide user-friendly experience on website. I choose to implement toggleable dark mode (reference to neon 80's theme) and light mode (classic style wtih accent colors) and , in the same time, it adds a some level interaction to my website.
What I used?
I found out there is a special plugin in Gatsby plugin library gatsby-plugin-dark-mode but I decided not to touch ready-to-use solution but to dive deep to custom one.
In order to implement dark-light mode I chose to stay with SSR and React Hooks as useEffect and useState.
Implementation
- First of all I decided to add theme item and its value to
localStorage
. I usedgatsby-ssr.js
to set preBodyComponent in order to have script uploaded as soon as possible.
const React = require('react')
exports.onRenderBody = ({ setPreBodyComponents }) => {
setPreBodyComponents([
React.createElement('script', {
dangerouslySetInnerHTML: {
__html: `
(() => {
window.__onThemeChange = function() {};
function setTheme(newTheme) {
window.__theme = newTheme;
preferredTheme = newTheme;
document.body.className = newTheme;
window.__onThemeChange(newTheme);
}
let preferredTheme
try {
preferredTheme = localStorage.getItem('theme')
} catch (err) {}
window.__setPreferredTheme = newTheme => {
setTheme(newTheme)
try {
localStorage.setItem('theme', newTheme)
} catch (err) {}
}
let darkQuery = window.matchMedia('(prefers-color-scheme: dark)')
darkQuery.addListener(e => {
window.__setPreferredTheme(e.matches ? 'light' : 'dark')
})
setTheme(preferredTheme || (darkQuery.matches ? 'light' : 'dark'))
})()
`,
},
}),
])
}
- After that I went to Header component and added our useEffect and useState hooks.
What does useEffect do?
By using this Hook, you tell React that your component needs to do something after render. React will remember the function you passed (we’ll refer to it as our “effect”), and call it later after performing the DOM updates.
useEffect(() => {
setTheme(window.__theme)
window.__onThemeChange = () => {
setTheme(window.__theme)
}
}, [])
Then I needed to add useState hook to trigger state change every time I want to switch theme.
There is a big BUT here. I faced up to using null
in useState hook that caused rendering Header twice every time clicking on theme toggler. The solution is to provide an initial state to prevent double render.
Here will be a screenshot
const [theme, setTheme] = useState(websiteTheme)
You can see that initial state of useState hook is websiteTheme
. It holds a window.__theme
value you can see in gatsby-ssr.js
. And I added a condition for server side rendering because THERE IS NO WINDOW while Gatsby is building website.
Kyle Mathews states:
During development, react components are only run in the browser where window is defined. When building, Gatsby renders these components on the server where window is not defined.
let websiteTheme
if (typeof window !== `undefined`) {
websiteTheme = window.__theme
}
In the end I added a ThemeToggle
function which toggles website theme between dark
and light
mode
const ThemeToggle = () => {
window.__setPreferredTheme(websiteTheme === 'dark' ? 'light' : 'dark')
}
and toggle button
<button onClick="{ThemeToggle}">
{theme === 'dark' ? (
<img src="{sun}" alt="Light mode" />
) : (
<img src="{moon}" alt="Dark mode" />
)}
</button>
Here is complete version of Header component:
// src/components/Header.index.js
import React, { useState, useEffect } from 'react'
import sun from '../../images/sun.svg'
import moon from '../../images/moon.svg'
const Header = props => {
let websiteTheme
if (typeof window !== `undefined`) {
websiteTheme = window.__theme
}
const [theme, setTheme] = useState(websiteTheme)
useEffect(() => {
setTheme(window.__theme)
window.__onThemeChange = () => {
setTheme(window.__theme)
}
}, [])
const ThemeToggle = () => {
window.__setPreferredTheme(websiteTheme === 'dark' ? 'light' : 'dark')
}
return (
...skipped...
<button onClick={ThemeToggle}>
{theme === 'dark' ? (
<img src={sun} alt="Light mode" />
) : (
<img src={moon} alt="Dark mode" />
)}
</button>
...skipped...
)
}
export default Header
So we are almost done. The last thing we need to add is out styles for dark
and light
theme. I used GlobalStyle
providing by styled-components
. Don't worry I will provide solution with css as well. So, we need to create a GlobalStyle.js component in style folder. Inside GlobalStyle.js file we type this:
// src/styles/GlobalStyle.js
import { createGlobalStyle } from 'styled-components'
export const GlobalStyle = createGlobalStyle`
body {
margin: 0;
padding: 0;
box-sizing: border-box;
background-color: var(--bg);
color: var(--textNormal);
&.dark {
--bg: #221133;
--textNormal: #fff;
}
&.light {
--bg: #fff;
--textNormal: #000;
}
`
After I go to Layout.js
component which is responsible for website layout and insert GlobalStyle
into it.
// src/layout/index.js
...skiped...
import { ThemeProvider } from 'styled-components'
import { GlobalStyle } from '../styles/GlobalStyle'
export default ({ children }) => {
return (
<ThemeProvider theme={styledTheme}>
<GlobalStyle />
<Header />
{children}
<Footer />
</ThemeProvider>
)
}
That's it! Every time you click on toggle button you will change theme between dark and light versions.
Thanks for reading and happy coding 😉 !
Useful links:
Top comments (8)
Sheesh... talk about convoluted and a NASTY case of "JS for nothing"
Set the DIV up to behave as a body replacement (I often have this in place anyways as a fix for the "double scroll bar" modal issue)...
Then just "#toggle_dayNight:checked + .dayNight {}" to set the override colours.
Not a single line of scripting needed. "hidden" is your new best friend.
JS for nothin' and your scripts for free. That ain't workin', that's not how you do it...
Some devs prefered write this way, some people use
checked
property. There is always a place to play with code. Everybody chooses what they prefer. Piece!Example code:
Again, not JavaScript's job if you can avoid it, since you're alienating large swaths of users. Much less onEvent attributes being relics of two decades ago that should have been axed at that time...
... and using some rubbish framework or pre-processor to do "templates" with it doesn't fix that. If anything, it simply proves that people are throwing JavaScript client-side in ways that are NONE of JavaScript's flipping business.
See 90%+ of what people do with mentally enfeebled trash like React or jQuery... the stuff I have to rip out in my day job of sites in court for WCAG violations!
So much pain is in the comment above 😀
Thanks for WCAG reference
Thanks for great post.
It seems to me there's a typo, and need to swap modes:
Also check your website too.
Hey,
Sorry for being quite vague but do you know how to get something like this working using next?
I have it all set up and working, however there is a flicker if you select a dark theme and then refresh the page. The reason for this is because the _app.js initial state is "light", meaning that it sets the light theme, then checks the local storage - finds the theme entry - and sets the theme dark.
So essentially afaik it goes: renders on server -> sends over light theme -> checks local storage and finds dark theme -> renders dark theme
I don't know how to get around this issue, other than blocking the entire render of the app while we check local storage for a theme?
Hi Amal,
I don't understand how this window.__onThemeChange function works.
First this function is declared: window.__onThemeChange = function() {};
And them it is called from inside of setTheme function:
function setTheme(newTheme) {
window.theme = newTheme;
window.onThemeChange(newTheme);
}
Then you use it in Component in useEffect by adding into body of this onThemeChange function change to Components state:
useEffect(() => {
toggleTheme(websiteTheme);
window.onThemeChange = () => {
toggleTheme(window.theme);
};
}, []);
How is it that you call window.__onThemeChange(newTheme) from within setTheme() when it is only declared as a empty function at the top of this file?
Can you explain how it works?
Thank you in advance.
why not just use the plugin rather than re-inventing the wheel, not trying to come across negative either