Sunset over the ocean with palm tree and railing of lanai in foreground. Island in the background.

Tailwind - Dark Mode

Installing Tailwind CSS with Gatsby - Part 2

...or Dark Mode in Paradise!

I did a lot of research for implementing Dark Mode and I wanted to do it with Tailwind CSS. Tailwind has its own implementation. I originally had the plan of implementing it like this. However, after researching how most developers were doing this, I saw benefit in using CSS Variables. All of this was new to me. I've never been a web designer and I haven't fully grokked CSS. See the Acknowledgements below for all the sites that gave me clues and information!

One of the main reasons I went with Tailwind CSS was due to the dark mode support in 2.0. Ironically (or perhaps not...not an expert on irony) I'm not normally using Tailwind's dark mode. Still, I'm enjoying a lot of Tailwind and there is plenty of normal CSS and some styled-components in the site as well!
One issue I kept running into was if I was using Tailwind with twin.macro, as I thought I must, I was going to be blocked from using Tailwind plugins. I had the idea that I'd use twin.macro so I'd have styled-components and Tailwind for best of both worlds!

I ended up working from Josh W. Comeau's "The Quest for the Perfect Dark Mode"

Turn on the dark in Tailwind Config

In tailwind.config.js the default for darkMode is false. My plan was to set a class of either light or dark as high up on the site as I could. So my first step was to set darkMode to class.

/tailwind.config.js
1module.exports = {
2 purge: ['./src/**/*.{js,jsx,ts,tsx}'],
3 darkMode: 'class', // or 'media' or 'class'
4 theme: {
5 extend: {},
6 },
7 variants: {},
8 plugins: [],
9}

Develop the pre-Gatsby script

As Josh pointed out in his post, dark mode truly was nasty tough to do in Gatsby. The script needs to run before Gatsby starts hydrating. His goal was the same as mine which was helpful! I wanted to know what the user wanted before the home page displayed.

What the user wanted was actually defined by Josh, and I agreed:

  • user can click a toggle to switch
  • user's preference should be saved
  • color scheme should default to OS setting
  • if all else fails, go with light mode

Josh had a couple of other requirements that I didn't think of or realize would be an issue:

  • no flicker on first load ⁉
  • site should never show the wrong toggle state (well, yes, of course...why would it?)

I followed Josh's steps, creating a ThemeContext and learning about React Context a bit.

His function was called getInitialColorMode while mine is getInitialTheme. They both do the same thing in the long run but since Tailwind was going to handle the colors, I only had to set the theme as light or dark and set a class on the body element.

This ends up in `gatsby-ssr.js`. Where it is at this moment in history is lost to me!
not-final-version.js
1import * as React from 'react'
2
3const MagicScriptTag = () => {
4 const codeToRunOnClient = `
5 (function() {
6 function getInitialTheme() {
7 const persistedColorPreference = window.localStorage.getItem('color-mode')
8 const hasPersistedPreference = typeof persistedColorPreference === 'string'
9 // If the user has explicitly chosen light or dark,
10 // let's use it. Otherwise, this value will be null.
11 if (hasPersistedPreference) {
12 return persistedColorPreference
13 }
14 // If they haven't been explicit, let's check the media
15 // query
16 const mql = window.matchMedia('(prefers-color-scheme: dark)')
17 const hasMediaQueryPreference = typeof mql.matches === 'boolean'
18 if (hasMediaQueryPreference) {
19 return mql.matches ? 'dark' : 'light'
20 }
21 // If they are using a browser/OS that doesn't support
22 // color themes, let's default to 'light'.
23 return 'light'
24 }
25
26 const theme = getInitialTheme()
27
28 const body = document.body
29
30 body.className = theme
31
32 const root = document.documentElement;
33 root.style.setProperty('--initial-theme', theme);
34 })()`
35
36 // eslint-disable-next-line react/no-danger
37 return <script dangerouslySetInnerHTML={{ __html: codeToRunOnClient }} />
38}
39export const onRenderBody = ({ setPreBodyComponents }) => {
40 setPreBodyComponents(<MagicScriptTag />)
41}

Dark Mode Toggle component

I went extremely simple on the toggle component. I decided to just use 🌞 and 🌙 emojis. Some of the sites I looked at had much better looking ideas and perhaps someday mine will too. The DarkToggle component uses theme context so it can access the theme and set it when it updates. I almost understood it actually! I added that to my header component.

Color scheme

Since there will be a light or dark class added to body, I can use CSS Variables to create .light and .dark styles that will live at the root and be available everywhere. I created Tailwind theme colors using those CSS variable names.

After that, dark mode worked!

So, San Jose Sharks fan here... I decided on light and dark themes based on Sharks colors. Really, the teal and orange ones mostly, although, their other colors are `#ffffff` and `#000000` and those make good surface colors!

I thought since I'm going with teal for my primary color, perhaps the blue-gray Tailwind color pallette would do well instead of the standard gray. I ended up using hex codes initially instead of Tailwind codes, however, and at the current state of the project (here on 2021-01-14) I'm still not using Tailwind codes.

The Recipe

  1. set darkMode = 'class' in tailwind.config.js

  2. create Theme Provider component

src/components/ThemeProvider.js
1import * as React from 'react'
2import { useState, useEffect } from 'react'
3
4export const ThemeContext = React.createContext()
5
6export const ThemeProvider = ({ children }) => {
7 const [theme, rawSetTheme] = useState(undefined)
8 useEffect(() => {
9 const root = window.document.documentElement
10 const initialTheme = root.style.getPropertyValue('--initial-theme')
11 rawSetTheme(initialTheme)
12 }, [])
13
14 const setTheme = (value) => {
15 rawSetTheme(value)
16
17 window.localStorage.setItem('theme', value)
18 window.document.documentElement.className = value
19 }
20
21 return <ThemeContext.Provider value={{ theme, setTheme }}>{children}</ThemeContext.Provider>
22}
  1. create App component
src/components/App.js
1// Your App.js may live elsewhere of course. I haven't decided it should be as of now!
2import * as React from 'react'
3import { ThemeProvider } from './ThemeContext'
4
5const App = ({ children }) => {
6 return <ThemeProvider>{children}</ThemeProvider>
7}
8
9export default App
  1. add App and Tailwind CSS to gatsby-browser.js
gatsby-browser.js
1import * as React from 'react'
2import App from './src/components/App'
3import './src/styles/global.css'
4
5export const wrapRootElement = ({ element }) => {
6 return <App>{element}</App>
7}
  1. create gatsby-ssr.js
gatsby-ssr.js
1import * as React from 'react'
2import App from './src/components/App'
3
4const MagicScriptTag = () => {
5 const codeToRunOnClient = `
6 (function() {
7 function getInitialTheme() {
8 const persistedColorPreference = window.localStorage.getItem('theme')
9 const hasPersistedPreference = typeof persistedColorPreference === 'string'
10 // If the user has explicitly chosen light or dark,
11 // let's use it. Otherwise, this value will be null.
12 if (hasPersistedPreference) {
13 return persistedColorPreference
14 }
15 // If they haven't been explicit, let's check the media
16 // query
17 const mql = window.matchMedia('(prefers-color-scheme: dark)')
18 const hasMediaQueryPreference = typeof mql.matches === 'boolean'
19 if (hasMediaQueryPreference) {
20 return mql.matches ? 'dark' : 'light'
21 }
22 // If they are using a browser/OS that doesn't support
23 // color themes, let's default to 'light'.
24 return 'light'
25 }
26
27 const theme = getInitialTheme()
28
29 const root = document.documentElement
30 root.className = theme
31 root.style.setProperty('--initial-theme', theme)
32 })()`
33
34 // eslint-disable-next-line react/no-danger
35 return <script dangerouslySetInnerHTML={{ __html: codeToRunOnClient }} />
36}
37export const onRenderBody = ({ setPreBodyComponents }) => {
38 setPreBodyComponents(<MagicScriptTag />)
39}
40
41export const wrapPageElement = ({ element }) => {
42 return <App>{element}</App>
43}
  1. create DarkToggle component
src/components/DarkToggle.js
1import * as React from 'react'
2import { useContext } from 'react'
3
4import { ThemeContext } from './ThemeContext'
5
6const DarkToggle = () => {
7 const { theme, setTheme } = useContext(ThemeContext)
8
9 if (!theme) {
10 return null
11 }
12
13 return (
14 <button
15 type='button'
16 onClick={() => {
17 setTheme(theme === 'light' ? 'dark' : 'light')
18 }}
19 className='block'
20 >
21 {`${theme === 'light' ? '🌙' : '🌞'}`}
22 </button>
23 )
24}
25
26export default DarkToggle
  1. add DarkToggle to Header Component and add some Tailwind to test it out.
src/components/Header.js
1import { Link } from 'gatsby'
2import PropTypes from 'prop-types'
3import * as React from 'react'
4
5import DarkToggle from './DarkToggle'
6
7const Header = ({ siteTitle }) => (
8 <header className='bg-gray-300 text-gray-900 dark:bg-gray-900 dark:text-gray-200 p-4 flex justify-between items-center'>
9 <h1 className='text-gray-900 dark:text-gray-200 no-underline m-0'>
10 <Link to='/'>{siteTitle}</Link>
11 </h1>
12 <DarkToggle />
13 </header>
14)
15
16Header.propTypes = {
17 siteTitle: PropTypes.string,
18}
19
20Header.defaultProps = {
21 siteTitle: ``,
22}
23
24export default Header
  1. add color scheme to Global CSS
/src/styles/global.css
1@tailwind base;
2@tailwind components;
3@tailwind utilities;
4
5.light {
6 --primary: #007889;
7 --background: #e5e7eb;
8 --surface: #ffffff;
9 --container: #d1d5db;
10 --branded-surface: #d1d5db;
11 --text: #1f2937;
12 --text-header: #09090a;
13 --link: #007889;
14 --muted: #4b5563;
15 --text-disabled: #6b7280;
16}
17
18.dark {
19 --primary: #f4901e;
20 --background: #1f2937;
21 --surface: #000000;
22 --container: #111827;
23 --branded-surface: #111a1c;
24 --text: #e5e7eb;
25 --text-header: #f3f4f6;
26 --link: #f4901e;
27 --muted: #9ca3af;
28 --text-disabled: #6b7280;
29}
  1. add color scheme to Tailwind CSS
tailwind.config.js
1module.exports = {
2 purge: ['./src/**/*.{js,jsx,ts,tsx}'],
3 darkMode: 'class', // or 'media' or 'class'
4 theme: {
5 extend: {
6 colors: {
7 primary: 'var(--primary)',
8 background: 'var(--background)',
9 surface: 'var(--surface)',
10 container: 'var(--container)',
11 brandedSurface: 'var(--branded-surface)',
12 body: 'var(--text)',
13 header: 'var(--text-header)',
14 link: 'var(--link)',
15 muted: 'var(--muted)',
16 disabled: 'var(--text-disabled)',
17 },
18 },
19 },
20 variants: {
21 extend: {},
22 },
23 plugins: [],
24}

At this point, my recipe is at an end. There were a few nice extra ideas in Josh's post but I decided against them, for now. I tried implementing the minification process he describes using Terser but I had too many problems for too little joy. I also decided to skip the situation when there is no JavaScript.

Acknowledgements

Attributions

Photo by Ken Feliciano (Me!)

Built with Gatsby from 2020 thru 2024 (and beyond?!)