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>
    )
}

2022 No rights reserved.
3D Heart