Using Vue Single-File Components Inside Shadow DOM

This is an old post!

This post is over 2 years old. Solutions referenced in this article may no longer be valid. Please consider this when utilizing any information referenced here.

Let’s say you’re building a tiny little Vue app. Not a full-on single page app, but something very tiny that will need to be embedded into other pages. Like a fully interactive widget that can do a wide variety of things, but will need to be self-contained so as not to interfere the rest of the page.

Traditionally, in the past, we did this with a wide variety of approaches. Going back to the 90s, we use Java Applets (remember those?) and Active-X controls (ugh). We used Flash too (double ugh). Lately the preferred approach has been iframes, and while this is still a perfectly valid approach, it has it’s own set of problems.

But now, we also have Shadow DOM which provides us another approach to building richly interactive widgets that are (mostly) contained from interfering with the styling of the surrounding page and, crucially, doesn’t allow the surrounding page to interfere with the widget!

And, yes, Vue can totally be used inside a shadow tree. It just take a bit of setup work.

To start, I am going to assume you have some familiarity with modern web development tools. That means you at least somewhat understand how tools like webpack, VueJS, and Vue Single-File Components work. If you’re not familiar with these tools, please take some time to read up on them.

The Problem

Let’s assume you’ve built a simple Vue components that looks something like this. Let’s also assume you have webpack and all the other things configured to compile the components and most of that already working.

<template>
    <div class="foo">
        <button @click="doThings" class="bar">Baz</button>
    </div>
</template>

<script>
export default {
  methods: {
    doThings() {
      alert("I did something!");
    }
  }
}
</script>

<style lang="scss" scoped>
.foo {
    .bar {
        display: block;
        background-color: red;
    }
}
</style>

One thing to keep in mind when building something like this is that every instance of a Vue component is also an instance of Vue. So you can render this into an HTML element of your choice.

<div class="container"></div>
import vue from 'vue';
import MyComponent from 'src/MyComponent';

let app = new Vue({
  el: document.querySelector(".container"),
  render: h => h(MyComponent, {})
});

Now, this will work just beautifully. You should see your Vue app mount onto the .container div and display the button. You should be able to click on it and have it do something.

But what happens if you do this?

<div class="container"></div>
<style>
button {
    border: 1px solid green;
}
</style>

You now have a green border on your button despite using scoped styles on your component. This is because the styles from the page are leaking into the component. This is actually by design and, in most cases, a good thing. It allows you to have a single stylesheet (or multiple stylesheets compiled into a single one) that wraps up common styles so that your scoped styles in components can be as few as possible.

But in this case, that’s not what we want. We want our component to only have the styles we want without the page styles leaking in. How do we do that?

Enter Shadow DOM

Shadow DOM gives us that ability. Think of Shadow DOM as an isolated, independent DOM tree attached to an existing element inside the current tree. Styles in the main tree do not leak into any shadow trees, and styles in those shadow trees are independent of the main tree and, importantly, independent of other shadow trees. Basically, each shadow tree is an independent tree with its own styling.

So with Shadow DOM, things get a little more complicated. Creating a shadow root is actually fairly straightforward. We might modify our code to look something like this:

import vue from 'vue';
import MyComponent from 'src/MyComponent';

let treeHead = document.querySelector(".container");
let holder   = document.createElement("div");
let shadow   = treeHead.attachShadow({mode: 'open'});
shadow.appendChild(holder);

let app = new Vue({
  el: holder,
  render: h => h(MyComponent, {})
});

Run that, and your element is now inside it’s own isolated shadow root!

… But it probably looks like garbage, and this is because the styles that you created on the single file component itself are not available in the shadow tree. They are only available in the main DOM tree. And to understand why this is happening, you need to understand how webpack’s style-loader works.

Style Loading

By default, the style-loader appends <style>/<link> elements to the end of the style target, which is the <head> tag of the page unless specified by insert. So by default, styles that webpack’s style-loader has pulled out of the Vue Single-File Component get put in the <head> tag. Again, this is a reasonable default for people building SPAs, but it’s not what we need. Fortunately, style-loader is flexible for other use cases.

In particular, style-loader allows you to use a function to determine where to put the style tags. And, this is important, that function is executed in the context of the page.

So, you might modify your webpack config file to do this:

  module: {
    rules: [
      {
        test: /\.s[ac]ss$/,
        use: [
          {
            loader: "style-loader",
            options: {
              insert: require("./css-loader-shim")
            },
          },
          "css-loader",
          {
            loader: "sass-loader",
            options: {
              implementation: require("sass"),
              sassOptions: {
                fibers: require("fibers"),
              }
            }
          }
        ]
      },
    ]

The important thing there is where we changed style-loader with an options object that has an insert property. That will load our loading shim. You could just add a closure right there in the webpack file, but I think it’s probably better to separate page logic from configuration and building as much as possible. Remember, that closure is executed on the page.

And create a css-loader-shim.js that looks like this:

module.exports = function(elem) {
  window["foo"] = window["foo"] || {};
  window["foo"].loadStyles = function(el) {
    el.appendChild(elem);
  }
}

So when the compiled Javascript is brought into the page, it will immediately execute that function with elem containing the style or link tag. But we don’t do anything with that immediately. Instead, we create a callback that we can call when we are ready to insert the styles at a location of our choosing. And that means inserting them into the shadow root!

As a quick note, this function is intentionally simple. It assumes you are only loading one file. If you are going to be loading multiple files (such as from using code splitting), that function will need to take into account which element is calling it and put the styles in the right location. This is meant to illustrate this approach, not be a be-all, end-all solution.

So finally, we’ll modify our application code above again:

import vue from 'vue';
import MyComponent from 'src/MyComponent';

let treeHead = document.querySelector(".container");
let parent   = document.createElement("div");
let holder   = document.createElement("div");
let shadow   = treeHead.attachShadow({mode: 'open'});

foo.loadStyles(parent);
parent.appendChild(holder);
shadow.appendChild(parent);

let app = new Vue({
  el: holder,
  render: h => h(MyComponent, {})
});

See what we did there? We called foo.loadStyles(parent) on a div we created, then inserted all that into the shadow root. So if you reload, now you should have a red button with no green border. And any buttons you had on the page would also not be red even if you applied the right classes. Everything from a style standpoint is isolated to that spot.

Browser Support

As of the writing of this post this approach is supported in 90% of browsers. Firefox and Chrome have supported it for years, and mobile browser support is good. Basically the only things that don’t support this approach are really old versions of Internet Explorer and pre-Chromium Microsoft Edge, as well as a smattering of other minor browsers. So it should be fairly safe to use.

However, if you need to support these ancient browsers, there is a shadow DOM polyfill that you can include as well.

Comments (0)

Interested in why you can't leave comments on my blog? Read the article about why comments are uniquely terrible and need to die. If you are still interested in commenting on this article, feel free to reach out to me directly and/or share it on social media.

Contact Me
Share It
Release Announcements
Impatient? Scroll to the bottom to download. So I recently was working on a site and wanted to use CSS3 columns. But I really like how the grid system works in Bootstrap, and wanted to be able to define columns in a similar way (i.e. have different number of columns depending on the screen size). Not finding any pre-cooked versions, I decided to write my own. Strictly speaking, you don’t need Bootstrap for this to work. But I did re-use Bootstrap’s grid variables so that it breaks along the same lines that Bootstrap’s grid does. It’s also worth noting that, natively, the columns will collapse on their own if you specify a width. This method just gives you a bit more control.
Read More
Release Announcements
petfeedd users, I am proud to announce the beta release of petfeedd 1.0.1. This release has no major changes in it and is solely about addressing security issues in many of the underlying libraries used by petfeedd. To install it or upgrade from previous versions, you can simply run: docker pull peckrob/petfeedd:latest
Read More
Release Announcements
After five beta releases and months of testing, I am happy to announce petfeedd Version 1.0 is now available. All changes from the beta branch have been merged in and the release is now available on Docker Hub. To install it or upgrade from Version 0.2, you can simply run: docker pull peckrob/petfeedd:latest And restart. It should perform all the upgrades needed for version 1.0.
Read More