12

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?

user341554
  • 569
  • 2
  • 8
  • 16
  • What exactly are you loading from ajax, because if you want to change your theme you can simple update your current `link` tag `href` attribute and your browser will perform the request itself. You can easily target the `link` tag by adding a `class` or `id` selector. – Stephan-v Oct 13 '17 at 14:41
  • 1
    Assuming you have freedom over how themes are defined: first scope all your themes to depend on the `class` of the `` tag (ie rules with selectors like `body.theme-red a { ... }`. Then, load `theme-red.css` using a `` tag. When it is `load`ed, swap the `` class attribute. – Botje Oct 13 '17 at 18:08

5 Answers5

22

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/vue@2.5.2/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!

ContinuousLoad
  • 4,692
  • 1
  • 14
  • 19
  • I apologize for the snippets not working for a while -- Bootswatch changed their URL format for the CSS files, so they stopped loading. Everything should be working again! Feel free to let me know if you experience troubles in the future. Thanks! – ContinuousLoad Nov 25 '17 at 21:26
  • 1
    Thanks for giving a solution. But I'm using scss in my vue project so i just want to override some of the variables defined in scss. So how can i achieve this? – RAVI PATEL May 02 '19 at 10:27
  • wow,, although this didnt work on others but for me this is a pretty good IDEA. nise nise. I would use this kind of idea. But, is their any other best way? or this idea is good? – Jenuel Ganawed Jan 16 '20 at 02:54
  • How would I do this with a local css file? I image it depends a lot on the build tools and such. I am using an spa app build with the vue CLI. I believe it makes use of webpack? – Montana May 16 '20 at 23:56
3

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

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

mikkom
  • 3,521
  • 5
  • 25
  • 39
2

how about this,

https://www.mynotepaper.com/create-multiple-themes-in-vuejs

and this,

https://vuedose.tips/tips/theming-using-custom-properties-in-vuejs-components/

I think that will give you a basic idea for your project.

Abid Khairy
  • 1,198
  • 11
  • 9
1

first of all I would like to thank ContinuousLoad for its inspiring code snippet. It helped me a lot to make my own theme chooser. I just wanted to give some feedback and share my changes to original code, specially in function preloadTheme. The biggest change was to remove the onload() event listener after initial load, because it would re-run each time you change the link.disabled value, at least under Firefox. Hope it helps :)

const ThemeHelper = function() {
  const preloadTheme = href => {
    let link = document.createElement('link');
    link.rel = 'stylesheet';
    link.disabled = false;
    link.href = href;

    return new Promise((resolve, reject) => {
      link.onload = function() {
        // Remove the onload() event listener after initial load, because some
        // browsers (like Firefox) could call onload() later again when changing
        // the link.disabled value.
        link.onload = null;
        link.disabled = true;
        resolve(link);
      };
      link.onerror = event => {
        link.onerror = null;
        reject(event);
      };
      document.head.appendChild(link);
    });
  };

  const selectTheme = (themes, name) => {
    if (name && !themes[name]) {
      throw new Error(`"${name}" has not been defined as a theme.`);
    }
    Object.keys(themes).forEach(n => {
      if (n !== name && !themes[n].disabled) themes[n].disabled = true;
    });
    if (themes[name].disabled) themes[name].disabled = false;
  };

  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/vue@2.5.2/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>
tbl0605
  • 11
  • 2