DEV Community

Cover image for πŸŒ™ How to implement darkmode with a Vue.js component
tq-bit
tq-bit

Posted on • Edited on

πŸŒ™ How to implement darkmode with a Vue.js component

Implementing darkmode in your webapp will be sugar for your nightowl readers. It implements a high-contrast color scheme that's soothing for the eyes when ones background light is dimmed or even absent. Toggling between dark - and light mode is a must-have for modern websites. So read on to learn how to write your own reusable Vue.js component to implement it.

The TL:DR - Code Sandbox

If you're after the component's source, check out this code sandbox.

https://codesandbox.io/s/immutable-monad-cotsz?file=/src/App.vue

Make sure to consider these two core points:

  • From App.vue, copy the :root and :root.dark-theme styles and add them to your own project's structure.
  • Then, copy the whole content of ThemeButton.vue into your own component file

You can then import and use <theme-button /> component wherever you would like to use it.

Update: Vue 3 + TS + Composition API

Check out the component on my blog

Getting started & prerequisites

To follow along on your local mashine, you will require a working version of Node.js and your favorite text editor, such as Visual Studio Code. While not mandatory, some experience with Vue or another Javascript framework will come in handy.

Create the app

This project will use Vite.js for bootstrapping. It's a toolkit comparable to the Vue CLI. Change to a directory of your choice and execute the following commands.



# Generate a vite-based app in the current directory
npm init @vitejs/app .

# Give the package a name, then install the necessary node modules
npm install && npm run dev


Enter fullscreen mode Exit fullscreen mode

This will create a fairly lean app structure based on @vitejs/create-app - perfect for our use case.

For the sake of simplicity, the following steps will all happen inside the App.js file.

I would encourage you to try and use a separate component though.

Create the base component structure

Now that the app is setup, let's start with some basic component structure.

Replace all contents of the Β App.vue file with the following:



<template>
  <div class="container-center">
    <div class="card">
      <input
        @change="toggleTheme"
        id="checkbox"
        type="checkbox"
        class="switch-checkbox"
      />
      <label for="checkbox" class="switch-label">
        <span>πŸŒ™</span>
        <span>β˜€οΈ</span>
        <div
          class="switch-toggle"
          :class="{ 'switch-toggle-checked': userTheme === 'dark-theme' }"
        ></div>
      </label>
      <p>Wer sitzt dort so spΓ€t, bei Nacht und Wind?</p>
      <p>Entwickler Clemens, mit einem Pint.</p>
      <p>Man hΓΆrt ihn seufzen, ziemlich hart -</p>
      <p>Sonntag ist's, die Deadline naht</p>
    </div>
  </div>
</template>


Enter fullscreen mode Exit fullscreen mode


<script>
export default {
  mounted() {
    const initUserTheme = this.getMediaPreference();
    this.setTheme(initUserTheme);
  },

  data() {
    return {
      userTheme: "light-theme",
    };
  },
};
</script>


Enter fullscreen mode Exit fullscreen mode


<style>
html, body {
  padding: 0;
  margin: 0;
}
/* Define styles for the default root window element */
:root {
  --background-color-primary: #ebebeb;
  --background-color-secondary: #fafafa;
  --accent-color: #cacaca;
  --text-primary-color: #222;
  --element-size: 4rem;
}

/* Define styles for the root window with dark - mode preference */
:root.dark-theme {
  --background-color-primary: #1e1e1e;
  --background-color-secondary: #2d2d30;
  --accent-color: #3f3f3f;
  --text-primary-color: #ddd;
}

p {
  color: var(--text-primary-color);
}

.container-center {
  background-color: var(--background-color-primary);
  height: 100vh;
  width: 100vw;
  display: flex;
  align-items: center;
  justify-content: center;
}

.card {
  padding: 2rem 4rem;
  height: 200px;
  width: 300px;
  text-align: center;
  border: 1px solid var(--accent-color);
  border-radius: 4px;
  background-color: var(--background-color-secondary);
}
</style>



Enter fullscreen mode Exit fullscreen mode

Then start your vite dev - server using npm run dev. You should see this when opening your browser:

Style the checkbox to look like a switch

Style the checkbox element

Since there is no browser-native switch element, we'll create our own. The easiest way to do so is making use of the connection between an input element and the label that describes it.

To do so, we have to make sure that the for attribute in the label tag points at the correct input element's id. In this case, both of them are named checkbox. Doing so will cause a click event that hits the label to be reflected by the checkbox.

That means: We can get rid of the checkbox and focus on styling the label.

Let's start by adding the following to the style - part of the App.vue file:



.switch-checkbox {
  display: none;
}


Enter fullscreen mode Exit fullscreen mode

Style the checkbox label

Next, let's look at the background. The switch is meant to be a component, so we have to make sure it's easily reusable and flexible for other applications. For that, let's take a step back and look into the :root css we've parsed before.

In case you're unfamiliar with this approach: Inside the root - scope, you can define globally valid css variables. These can be used all across the app and offer great potential for reusability. If you're curious, read more about it on MDN



:root {
  --background-color-primary: #ebebeb;
  --background-color-secondary: #fafafa;
  --accent-color: #cacaca;
  --text-primary-color: #222;
  --element-size: 4rem; /* <- this is the base size of our element */
}


Enter fullscreen mode Exit fullscreen mode

To give us a bit of flexibility regarding the switch's size, we'll make use of the --element-size css variable and use the calc() function to compute all other dimensions based on it. Since the width of the label is its biggest measurement, we'll bind its value to our root's variable.

In a nutshell: We'll use one css variable to describe the scale of the switch

With that in mind, add the following to the style - part of the App.vue file:



.switch-label {
  /* for width, use the standard element-size */
  width: var(--element-size); 

  /* for other dimensions, calculate values based on it */
  border-radius: var(--element-size);
  border: calc(var(--element-size) * 0.025) solid var(--accent-color);
  padding: calc(var(--element-size) * 0.1);
  font-size: calc(var(--element-size) * 0.3);
  height: calc(var(--element-size) * 0.35);

  align-items: center;
  background: var(--text-primary-color);
  cursor: pointer;
  display: flex;
  position: relative;
  transition: background 0.5s ease;
  justify-content: space-between;
  z-index: 1;
} 


Enter fullscreen mode Exit fullscreen mode

If you open your browser now, you'll note that one core element is still missing: The actual toggle ball. Let's add it next.

Style the switch's toggle

To finalize the switch, add the following to the style - part of the App.vue file:



.switch-toggle {
  position: absolute;
  background-color: var(--background-color-primary);
  border-radius: 50%;
  top: calc(var(--element-size) * 0.07);
  left: calc(var(--element-size) * 0.07);
  height: calc(var(--element-size) * 0.4);
  width: calc(var(--element-size) * 0.4);
  transform: translateX(0);
  transition: transform 0.3s ease, background-color 0.5s ease;
}


Enter fullscreen mode Exit fullscreen mode

Now, almost finished, actually. The toggle looks done, but clicking on it won't result in the usual toggle - effect. To overcome this, we'll use a Vue.js feature - dynamic class binding.

We already have one data property available in our component we can use for that purpose:



// In the script - part of App.vue 
data() {
  return {
    userTheme: "light-theme",
  };
},


Enter fullscreen mode Exit fullscreen mode

As you can see in the html - template, we're already dynamically binding a class based on userTheme.



<!-- In the template part of App.vue -->
<label for="checkbox" class="switch-label">
  <span>πŸŒ™</span>
  <span>β˜€οΈ</span>
  <div
    class="switch-toggle"
    :class="{ 'switch-toggle-checked': userTheme === 'dark-theme' }"
  ></div>
</label>


Enter fullscreen mode Exit fullscreen mode

So let's add this class's definition in our style - part:



.switch-toggle-checked {
  transform: translateX(calc(var(--element-size) * 0.6)) !important;
}


Enter fullscreen mode Exit fullscreen mode

That wraps up the styling of the switch. Finally, let's add the functionality to handle light - and darkmode.

Implement the dark-mode switch

All left to do is to dynamically add and remove the .dark-mode and .light-mode class to our window root element. Based on that, one of the two root - variable scopes will be enforced. Β To round things up, we'll also use localStorage to add some persistence.

Manually toggle between the themes

Start by adding the following method to the script part of the App.vue file:



methods: {
  setTheme(theme) {
    localStorage.setItem("user-theme", theme);
    this.userTheme = theme;
    document.documentElement.className = theme;
  }
}


Enter fullscreen mode Exit fullscreen mode

Next, we will need to consider what happens when the user clicks on the switch. We want to read out the local storage value for the user theme and, based on it, execute the setTheme method form above. Let's add the next method straight away:



toggleTheme() {
  const activeTheme = localStorage.getItem("user-theme");
  if (activeTheme === "light-theme") {
    this.setTheme("dark-theme");
  } else {
    this.setTheme("light-theme");
  }
}


Enter fullscreen mode Exit fullscreen mode

Recognize user preferences

The final step is to initially set a user theme based on the user's browser settings. In order to do so, we'll make use of the (prefers-color-scheme: dark) css selector. It is available to Javascript's window.matchMedia() method and returns true, if our user's browser prefers dark themes, or false if not.

Let's add this codepiece to the App.vue file's methods section. It will be called by the already available mounted() method when the app loads.



getMediaPreference() {
  const hasDarkPreference = window.matchMedia(
    "(prefers-color-scheme: dark)"
  ).matches;
  if (hasDarkPreference) {
    return "dark-theme";
  } else {
    return "light-theme";
  }
},


Enter fullscreen mode Exit fullscreen mode

Remember the user's current preference

While it's already convenient to recognize a visitor's system settings, you can even go further. Assuming a user views your page in dark-mode, it will bounce back once the browser closes. You can persist their choice with a few more lines of code



getTheme() {
  return localStorage.getItem("user-theme");
},


Enter fullscreen mode Exit fullscreen mode

Finally, let's add the initial theme setting to the mounted - lifecycle hook.

If the user had no previous preference, your app will simply fall back to their system settings



mounted() {
  const initUserTheme = this.getTheme() || this.getMediaPreference();
  this.setTheme(initUserTheme);
},


Enter fullscreen mode Exit fullscreen mode

Thank you @violacase for pointing this one out

And that's it. You'll now be looking at a fully functional theme switch, ready to be thrown into any new or existing project. Try and give it a shot, maybe play around with the element sizes and calc() a bit to find the fitting size for your appliance.

Further reading

While fairly simple to customize, there's some learning to be done to correctly implement a fully fledged dark theme for your website. Check out the following links to learn more on the topic and find some useful resources

Material design on dark colors

https://www.material.io/design/color/dark-theme.html

A color palette finder for your next dark theme

https://www.color-hex.com/color-palettes/?keyword=dark

A webapp to create a dark css theme for your website

https://nighteye.app/dark-css-generator/


This post was originally published at https://blog.q-bit.me/dark-mode-toggle-component-with-vue-js/
Thank you for reading. If you enjoyed this article, let's stay in touch on Twitter 🐀 @qbitme

Top comments (11)

Collapse
 
asparoth profile image
NoobDev

Hi there!
When I move pages, the button always has the toggle animation (even though the correct theme is active) when I enable dark mode.
So if I go from one page to the other, the toggle will initially move again while keeping the theme intact.

Any ideas? I went over your code and it looks the exact same for the script part.

Collapse
 
tqbit profile image
tq-bit

Are you using the component within a router-view? It's possible the animation gets triggered whenever the component re-renders.

mounted() {
  const initUserTheme = this.getTheme() || this.getMediaPreference();
  this.setTheme(initUserTheme);
}
Enter fullscreen mode Exit fullscreen mode

You could try and place the component outside of <vue-router />.

Alternatively, you could try to abstract the CSS positioning into a separate class & dynamically apply it to the template:

<label for="checkbox" class="switch-label">
  <span>πŸŒ™</span>
  <span>β˜€οΈ</span>
  <div
    class="switch-toggle"
    :class="{ 
        'switch-toggle-checked': userTheme === 'dark-theme', 
        'switch-toggle-unchecked': userTheme === 'light-theme'
     }"
  ></div>
</label>
Enter fullscreen mode Exit fullscreen mode

If you can, please share your source code and I'll have a closer look :-)

Collapse
 
asparoth profile image
NoobDev • Edited

So what I found out is that the transition css element should only be triggered on a click and now it triggers on every load.
The second solution didn't do it for me either.
My component is not being triggered within the vue router, but i found a different solution which is a little bit hacky:

.switch-toggle-checked {
transform: translateX(calc(var(--element-size) * 0.6)) !important;
transition: none;
}

This only shows the transition animation if you're in light mode.

Thank you a lot for this tutorial! It helped me a lot

Collapse
 
violacase profile image
violacase • Edited

Odd that I am the first one to react here. For this article is GREAT!
And above all: All steps work till the very end. Congrats.

Collapse
 
violacase profile image
violacase • Edited

That being said... getMediaPreference() is not a good idea for lots of reasons.
Simply replace it with a get from localStorage with f.i.:

getTheme() {
this.setTheme(localStorage.getItem("user-theme"))
},

Collapse
 
tqbit profile image
tq-bit

Thank you for your reply. & you got a valid point. I'll add your suggestion here & in the code sandbox. Will keep the getMediaPreference() as a default tho

Thread Thread
 
violacase profile image
violacase

Why keep it as a default? It really is BAD. My solution is SIMPLE, easy to implement and last but not least: secure and without any issues on all kind of browsers. My 2 pennies.

Thread Thread
 
tqbit profile image
tq-bit

With default, I meant as much as 'if there's no previous user preference in localStorage, use the result from getUserPreference. Please pick me up though on what's bad about reading out "(prefers-color-scheme: dark)" (besides missing support for IE11). I've seen it in other implementations and never had any issues using it.

Thread Thread
 
violacase profile image
violacase • Edited

OkΓ©. I was in error. Nothing wrong with getUserPreference. Thanks and good luck with all your work.:-)

BTW: for just toggling between two color themes you can also do it with plain CSS. See developer.mozilla.org/en-US/docs/W...

Thread Thread
 
tqbit profile image
tq-bit

Mh, how would you do this? prefers-color-scheme is a read-only attribute in the browser's context.

In another project (w/o Vue), I'm using a different approach with SASS though, creating a function & a few mixins to apply themes

In a styles/state.scss file:

:root {
  --light-bg-color: #{$color-light-white};
  --light-text-color: #{$color-dark-grey};
  --bg-color: #{$color-white};
  --text-color: #{$color-black};
  --dark-bg-color: #{$color-light-white};
  --dark-text-color: #{$color-dark-grey};
}

:root.dark-theme {
  --light-bg-color: #{$color-dark-grey};
  --light-text-color: #{$color-light-white};
  --bg-color: #{$color-grey};
  --text-color: #{$color-white};
  --dark-bg-color: #{$color-dark-grey};
  --dark-text-color: #{$color-white};
}

@function theme-color($for) {
  @return var(--#{$for}-color);
}

@mixin theme-colors {
  background-color: theme-color('bg');
  color: theme-color('text');
  fill: theme-color('text');
}

@mixin light-theme-colors {
  background-color: theme-color('light-bg');
  color: theme-color('light-text');
  fill: theme-color('light-text');
}

@mixin dark-theme-colors {
  background-color: theme-color('dark-bg');
  color: theme-color('dark-text');
  fill: theme-color('dark-text');
}
Enter fullscreen mode Exit fullscreen mode

To apply it to the whole HTML - site: in styles/index.scss:

@import './state.scss';
html {
  @include dark-theme-colors;
}
Enter fullscreen mode Exit fullscreen mode

The JS code is quite similar to the one in the article as well

Thread Thread
 
violacase profile image
violacase

Right now I'm working on a multi themes setup. I'll come back to you later when a demo project has been finished. Will take me a couple of days (in my spare time).