Creating a dark theme switch with Tailwind & Framer Motion
Dark themes are all the rage, most of the sites you visit today will have some sort of dark theme switch. Allowing you to switch between a light theme and a dark theme on the site you're visiting.
I will hopefully explain how to create an awesome switch using a little bit of Tailwind and Frame Motion. Framer motion is an animation library for React, it's super cool and I recommend that you check it out.
This is what we will be knocking up today.
First let's install framer and then import it into our component
npm install framer-motion
Once installed let's add it to our component.
import { motion } from "framer-motion"
We need to then import useState
from React so we can capture the state of isOn
our component should look something like this now.
import React, { useState} from 'react'
import {motion} from 'framer-motion'
export default function DarkModeSwitch(){
const [isOn, setIsOn] = useState(false)
return()
}
Above we have a state of false
to isOn
we're currently returning nothing but let's change that now.
If you take a look at the Framer example it looks very straightforward. With the example, they're using vanilla CSS. Let's use Tailwind CSS with ours.
First, we need to create a container div
for our switch.
<div className={`flex-start flex h-[50px] w-[100px] rounded-[50px] bg-zinc-100 p-[5px] shadow-inner hover:cursor-pointer dark:bg-zinc-700 ${ isOn && 'place-content-end'}`}></div>
I have included a ternary operator in my className
string this is because we need to conditional move the switch when isOn
is true or false.
${isOn && 'place-content-end'}`}
We're using place-content-end here which allows us to place the element at the end of its container. This is similar to using justify-end
in Tailwind. The other styles in className
are just for my preference you can change these to what you like.
Now we have our container div, let's give it some magic. We need to give it an onClick
attribute. So let's do that now.
<div onClick={toggleSwitch} className={`flex-start flex h-[50px] w-[100px] rounded-[50px] bg-zinc-100 p-[5px] shadow-inner hover:cursor-pointer dark:bg-zinc-700 ${ isOn && 'place-content-end'}`}></div>
As you can see we have given the onClick
a function to execute so let's add that and the div container into our component.
import React, { useState} from 'react'
import {motion} from 'framer-motion'
export default function DarkModeSwitch(){
const [isOn, setIsOn] = useState(false)
const toggleSwitch = () => setIsOn(!isOn)
return(
<div onClick={toggleSwitch} className={`flex-start flex h-[50px] w-[100px] rounded-[50px] bg-zinc-100 p-[5px] shadow-inner hover:cursor-pointer dark:bg-zinc-700 ${ isOn && 'place-content-end'}`}></div>
)
}
What are we doing with then toggleSwitch
why aren't we setting it true? I will explain that later but for now let's leave it the way it is. Now time to add the switch. With the container div we should just have a rectangle with rounded edges, let's change that now.
This is where motion comes in, we need to create another div
but this time it will be a motion.div
this allows us to give it some frame magic. Let's add that below with some classes from Tailwind.
import React, { useState} from 'react'
import {motion} from 'framer-motion'
export default function DarkModeSwitch(){
const [isOn, setIsOn] = useState(false)
const toggleSwitch = () => setIsOn(!isOn)
return(
<div onClick={toggleSwitch} className={`flex-start flex h-[50px] w-[100px] rounded-[50px] bg-zinc-100 p-[5px] shadow-inner hover:cursor-pointer dark:bg-zinc-700 ${ isOn && 'place-content-end'}`}>
<motion.div
className="flex h-[40px] w-[40px] items-center justify-center rounded-full bg-black/90"
layout
transition={spring}
>
</motion.div>
</div>
)
}
We now have out motion.div
with the additional attributes of layout
and transition
let's go through those now.
layout: boolean
| "position"
| "size"
If true
, this component will automatically animate to its new position when its layout changes. More info here
transition: Transition
Defines a new default transition for the entire tree. More info here
Let's add our transition
animations, this is going to be an object like so.
const spring = {
type: 'spring',
stiffness: 700,
damping: 30,
}
- spring: An animation that simulates spring physics for realistic motion.
- stiffness: Stiffness of the spring. Higher values will create more sudden movement. Set to 100 by default.
- damping: Strength of opposing force. If set to 0, spring will oscillate indefinitely. Set to 10 by default.
After adding our motion.div
and spring
object we should have something like this:
import React, { useState} from 'react'
import {motion} from 'framer-motion'
export default function DarkModeSwitch(){
const [isOn, setIsOn] = useState(false)
const toggleSwitch = () => setIsOn(!isOn)
const spring = {
type: 'spring',
stiffness: 700,
damping: 30,
}
return(
<div onClick={toggleSwitch} className={`flex-start flex h-[50px] w-[100px] rounded-[50px] bg-zinc-100 p-[5px] shadow-inner hover:cursor-pointer dark:bg-zinc-700 ${ isOn && 'place-content-end'}`}>
<motion.div
className="flex h-[40px] w-[40px] items-center justify-center rounded-full bg-black/90"
layout
transition={spring}
>
</motion.div>
</div>
)
}
This would be our finished switch, but wait there is more..what about the icons and the cool click animation??? Ok, so let's install React Icons and grab those icons.
Install React Icons via npm.
npm install react-icons --save
I have chosen the following icons, they're from the Remix library. Let's add those now.
import React, { useState} from 'react'
import {motion} from 'framer-motion'
import {RiMoonClearFill, RiSunFill} from 'react-icons/ri'
...
Now we need to place our icons, inside of our toggle switch. Our toggle switch is the motion.div
we made earlier. This stage is pretty simple, we just need to create another motion.div
inside of the parent motion.div
and give it some ternary operators and a whileTape
attribute like so:
<motion.div whileTap={{rotate: 360}}>
{isOn ? (<RiSunFill className="h-6 w-6 text-yellow-300" />) : (<RiMoonClearFill className="h-6 w-6 text-slate-200" />)}
</motion.div>
You can give your icons your own styling but this is how I have set mine up. Using the ternary operator allows us to switch the icon on the status of isOn
we should now have the following:
import {motion} from 'framer-motion'
import React, {useState} from 'react'
import {RiMoonClearFill, RiSunFill} from 'react-icons/ri'
export default function DarkModeSwitch(){
const [isOn, setIsOn] = useState(false)
const toggleSwitch = () => setIsOn(!isOn)
const spring = {
type: 'spring',
stiffness: 700,
damping: 30,
}
return(
<div onClick={toggleSwitch} className={`flex-start flex h-[50px] w-[100px] rounded-[50px] bg-zinc-100 p-[5px] shadow-inner hover:cursor-pointer dark:bg-zinc-700 ${ isOn && 'place-content-end'}`}>
<motion.div
className="flex h-[40px] w-[40px] items-center justify-center rounded-full bg-black/90"
layout
transition={spring}
>
<motion.div whileTap={{rotate: 360}}>
{isOn ? (<RiSunFill className="h-6 w-6 text-yellow-300" />) : (<RiMoonClearFill className="h-6 w-6 text-slate-200" />)}
</motion.div>
</motion.div>
</div>
)
}
Adding into Local Storage
Now we have a working component, but it's not completely done we need to handle our dark mode with localStrogae
so the user can keep their preference for next time. Reading over the Tailwind Docs on dark mode, we need to be able to toggle dark mode manually. To do this we need to add darkMode: 'class',
into our tailwind.config.js
file. Something like this.
module.exports = {
darkMode: 'class',
...
Now we can toggle dark mode manually via the switch. I have used the example on the Tailwind website for supporting light mode, dark mode, as well as respecting the operating system preference. However I have tweaked it a little bit, remember the state const [isOn, setIsOn] = useState(false)
lets change that to read localStorage
and check if the theme
is set to light
// before
const [isOn, setIsOn] = useState(false)
// after
const [isOn, setIsOn] = useState(() => {
if (localStorage.getItem('theme') === 'light') {
return true
} else {
return false
}
})
Instead of the state returning false
it fires off a function and checks if the theme within local storage is light
if it is, isOn
is true if not it's false. Now let's use the state of isOn
to manage the theme within local storage.
if (isOn) {
document.documentElement.classList.remove('dark')
localStorage.setItem('theme', 'light')
} else {
document.documentElement.classList.add('dark')
localStorage.setItem('theme', 'dark')
}
The above will do the following:
<!-- Dark mode not enabled -->
<html>
<body>
<!-- Will be white -->
<div class="bg-white dark:bg-black">
<!-- ... -->
</div>
</body>
</html>
<!-- Dark mode enabled -->
<html class="dark">
<body>
<!-- Will be black -->
<div class="bg-white dark:bg-black">
<!-- ... -->
</div>
</body>
</html>
Lastly, we add the following which allows us to avoid FOUC when changing themes of page loads
if (
localStorage.theme === 'light' || (!('theme' in localStorage) && window.matchMedia('(prefers-color-scheme: light)').matches)
) { document.documentElement.classList.add('dark') }
else {
document.documentElement.classList.remove('dark')
}
So that's it...our final component should look like this...
import {motion} from 'framer-motion'
import React, {useState} from 'react'
import {RiMoonClearFill, RiSunFill} from 'react-icons/ri'
export default function DarkModeSwitch(){
const [isOn, setIsOn] = useState(() => {
if (localStorage.getItem('theme') === 'light') {
return true
} else {
return false
}
})
const toggleSwitch = () => setIsOn(!isOn)
const spring = {
type: 'spring',
stiffness: 700,
damping: 30,
}
if (isOn) {
document.documentElement.classList.remove('dark')
localStorage.setItem('theme', 'light')
} else {
document.documentElement.classList.add('dark')
localStorage.setItem('theme', 'dark')
}
if (
localStorage.theme === 'light' || (!('theme' in localStorage) && window.matchMedia('(prefers-color-scheme: light)').matches)
) { document.documentElement.classList.add('dark') }
else {
document.documentElement.classList.remove('dark')
}
return(
<div onClick={toggleSwitch} className={`flex-start flex h-[50px] w-[100px] rounded-[50px] bg-zinc-100 p-[5px] shadow-inner hover:cursor-pointer dark:bg-zinc-700 ${ isOn && 'place-content-end'}`}>
<motion.div
className="flex h-[40px] w-[40px] items-center justify-center rounded-full bg-black/90"
layout
transition={spring}
>
<motion.div whileTap={{rotate: 360}}>
{isOn ? (<RiSunFill className="h-6 w-6 text-yellow-300" />) : (<RiMoonClearFill className="h-6 w-6 text-slate-200" />)}
</motion.div>
</motion.div>
</div>
)
}