Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

User-switchable custom themes with Vue.js

I have a VueJS app that will come with many different themes (at least 20 or so). Each theme stylesheet not only changes things like color and font size, but also the position and layout of some elements as well.

I want the user to be able to switch between these themes dynamically. So, at runtime, the user will be able to open an Options menu and select from a dropdown.

What is the cleanest way to have many dynamic user-selectable themes in VueJS?


I've thought of a couple of ways, such as:

  • Dynamically inserting a <link> or <style> tag. While this might work, I don't really see it as particularly "clean", and if I'm loading from AJAX, then oftentimes I'll see a FOUC.
  • Simply changing the Vue class bindings through a computed property. Something like having a if-else chain for every supported theme in every component. I don't particularly like this solution, as then every component I make will need to be updated every time I add a new theme later on.

In React, I think there's a plugin or something that has a <ThemeProvider> component, where adding a theme is as simple as wrapping it, i.e. <ThemeProvider theme={themeProp}><MyComponent></ThemeProvider>, and all styles in that theme will apply to that component and all child components.

Does VueJS have something similar, or is there a way to implement it?

like image 281
user341554 Avatar asked Oct 13 '17 13:10

user341554


3 Answers

Today I found possibly the simplest way to solve this and it even works with SCSS (no need to have separate CSS for each theme, which is important if your themes are based on one library and you only want to define the changes), but it needs

  1. Make an .scss/.css file for each theme
  2. Make these available somewhere in the src folder, src/bootstrap-themes/dark.scss for example
  3. Import the .scss with a condition in the App.vue, in the created:, for example
if (Vue.$cookies.get('darkmode') === 'true') {
     import('../bootstrap-themes/dark.scss');
     this.nightmode = true;
} else {
     import('../bootstrap-themes/light.scss');
     this.nightmode = false;
}

When the user lands on the page, I read the cookies and see if they left nightmode enabled when they left last time and load the correct scss

When they use the switch to change the theme, this method is called, which saves the cookie and reloads the page, which will then read the cookie and load the correct scss

setTheme(nightmode) {
     this.$cookies.set("darkmode", nightmode, "7d")
     this.$router.go()
}
like image 129
Daniel Szabo Avatar answered Oct 20 '22 16:10

Daniel Szabo


I will admit I had some fun with this one. This solution does not depend on Vue, but it can easily by used by Vue. Here we go!

My goal is to create a "particularly clean" dynamic insertion of <link> stylesheets which should not result in a FOUC.

I created a class (technically, it's a constructor function, but you know what I mean) called ThemeHelper, which works like this:

  • myThemeHelper.add(themeName, href) will preload a stylesheet from href (a URL) with stylesheet.disabled = true, and give it a name (just for keeping track of it). This returns a Promise that resolves to a CSSStyleSheet when the stylesheet's onload is called.
  • myThemeHelper.theme = "<theme name>"(setter) select a theme to apply. The previous theme is disabled, and the given theme is enabled. The switch happens quickly because the stylesheet has already been pre-loaded by .add.
  • myThemeHelper.theme (getter) returns the current theme name.

The class itself is 33 lines. I made a snippet that switches between some Bootswatch themes, since those CSS files are pretty large (100Kb+).

const ThemeHelper = function() {
 
  const preloadTheme = (href) => {
    let link = document.createElement('link');
    link.rel = "stylesheet";
    link.href = href;
    document.head.appendChild(link);
    
    return new Promise((resolve, reject) => {
      link.onload = e => {
        const sheet = e.target.sheet;
        sheet.disabled = true;
        resolve(sheet);
      };
      link.onerror = reject;
    });
  };
  
  const selectTheme = (themes, name) => {
    if (name && !themes[name]) {
      throw new Error(`"${name}" has not been defined as a theme.`); 
    }
    Object.keys(themes).forEach(n => themes[n].disabled = (n !== name));
  }
  
  let themes = {};

  return {
    add(name, href) { return preloadTheme(href).then(s => themes[name] = s) },
    set theme(name) { selectTheme(themes, name) },
    get theme() { return Object.keys(themes).find(n => !themes[n].disabled) }
  };
};

const themes = {
  flatly: "https://bootswatch.com/4/flatly/bootstrap.min.css",
  materia: "https://bootswatch.com/4/materia/bootstrap.min.css",
  solar: "https://bootswatch.com/4/solar/bootstrap.min.css"
};

const themeHelper = new ThemeHelper();

let added = Object.keys(themes).map(n => themeHelper.add(n, themes[n]));

Promise.all(added).then(sheets => {
  console.log(`${sheets.length} themes loaded`);
  themeHelper.theme = "materia";
});
<h3>Click a button to select a theme</h3>

<button 
  class="btn btn-primary" 
  onclick="themeHelper.theme='materia'">Paper theme
  </button>
  
<button 
  class="btn btn-primary" 
  onclick="themeHelper.theme='flatly'">Flatly theme
</button>

<button 
  class="btn btn-primary" 
  onclick="themeHelper.theme='solar'">Solar theme
</button>

It is not hard to tell that I'm all about ES6 (and maybe I overused const just a bit :)

As far as Vue goes, you could make a component that wraps a <select>:

const ThemeHelper = function() {
 
  const preloadTheme = (href) => {
    let link = document.createElement('link');
    link.rel = "stylesheet";
    link.href = href;
    document.head.appendChild(link);
    
    return new Promise((resolve, reject) => {
      link.onload = e => {
        const sheet = e.target.sheet;
        sheet.disabled = true;
        resolve(sheet);
      };
      link.onerror = reject;
    });
  };
  
  const selectTheme = (themes, name) => {
    if (name && !themes[name]) {
      throw new Error(`"${name}" has not been defined as a theme.`); 
    }
    Object.keys(themes).forEach(n => themes[n].disabled = (n !== name));
  }
  
  let themes = {};

  return {
    add(name, href) { return preloadTheme(href).then(s => themes[name] = s) },
    set theme(name) { selectTheme(themes, name) },
    get theme() { return Object.keys(themes).find(n => !themes[n].disabled) }
  };
};

let app = new Vue({
  el: '#app',
  data() {
    return {
      themes: {
        flatly: "https://bootswatch.com/4/flatly/bootstrap.min.css",
        materia: "https://bootswatch.com/4/materia/bootstrap.min.css",
        solar: "https://bootswatch.com/4/solar/bootstrap.min.css"
      },
      themeHelper: new ThemeHelper(),
      loading: true,
    }
  },
  created() {
    // add/load themes
    let added = Object.keys(this.themes).map(name => {
      return this.themeHelper.add(name, this.themes[name]);
    });

    Promise.all(added).then(sheets => {
      console.log(`${sheets.length} themes loaded`);
      this.loading = false;
      this.themeHelper.theme = "flatly";
    });
  }
});
<script src="https://unpkg.com/[email protected]/dist/vue.js"></script>

<div id="app">
  <p v-if="loading">loading...</p>

  <select v-model="themeHelper.theme">
    <option v-for="(href, name) of themes" v-bind:value="name">
      {{ name }}
    </option>
  </select>
  <span>Selected: {{ themeHelper.theme }}</span>
</div>

<hr>

<h3>Select a theme above</h3>
<button class="btn btn-primary">A Button</button>

I hope this is as useful to you as it was fun for me!

like image 42
ContinuousLoad Avatar answered Oct 20 '22 15:10

ContinuousLoad


One very simple and working approach: Just change the css class of your body dynamically.

like image 2
mikkom Avatar answered Oct 20 '22 17:10

mikkom