Encapsulating CSS with Shadow DOM

Jose A. Cabaneros
Motive.co
Published in
5 min readFeb 17, 2023

It isn’t always easy to find the best technology to use to deliver the greatest user experience, particularly when many different factors are at stake.

That’s precisely the case for Motive Commerce Search and how we ended up choosing Shadow DOM to encapsulate our search layer so that no conflict would arise between our search’s CSS and that of our clients’ shops.

Shadow DOM is the perfect tool for the encapsulation of DOM subtrees in the regular DOM, so that they can keep their properties isolated from the other code in a page, ensuring those different parts do not clash, and keeping a tidier code.

Following a similar process to those of preparing for Black Friday traffic and choosing Nuxt3 for our website, choosing Shadow DOM for the aforementioned problem was the result of researching and testing. Keep reading to learn more about how we ended up implementing it.

Encapsulation with Shadow DOM

As a recurring factor in agile methodology, and especially relevant for a product like Motive Commerce Search -which interacts with different eCommerce shops- the coexistence of different components requires a good integration.

Component-based development can sometimes be tricky, and we must ensure that our CSS styles are applied to shops correctly, no failure. Most browsers deal with singular DOM trees that represent the whole page, but we need to apply our front-end so that it is isolated from the CSS of the shop.

Therefore, we’re using Shadow DOM to create our own DOM tree that is isolated from the regular one, allowing our CSS to be encapsulated and making sure its effects work correctly against the CSS developed for the global area unless allowed by the component.

Here are some concepts to understand about the Shadow DOM encapsulation:

  • Shadow DOM Host: The element in the main document that “hosts” a Shadow DOM tree.
  • Shadow DOM Root: The element that serves as the entry point for the Shadow DOM tree, and all of its child elements are encapsulated within the subtree.
  • Shadow DOM Subtree: A tree inside the main document tree.
  • Shadow DOM Boundary: An imaginary line between where the Shadow DOM ends and the main Document tree begins.

The shops of our customers and our own front-end development is broken into a vast array of components to make it easier to code smaller batches. Breaking down a larger project into smaller, more manageable pieces makes it easier to review and manage code changes. Smaller batches can be tested and reviewed more easily, and if there are any issues or bugs, it’s easier to pinpoint the problem when the changes are smaller.

The CSS of our search and the shops get along well

Shadow DOM can be used to encapsulate the CSS of a search layer in an online shop to prevent the styles from being affected by the global CSS rules of the website. Here is a short guide on how to do it.

The Motive Search layer has a dynamic property called isolated to flag if the search layer must be embedded in the customer site as part of the regular DOM, or inside a Shadow DOM context.

In order to inject the CSS of the search layer in the root or inside the Shadow DOM, depending on the isolated flag, we created a CSS injector. The CSS injector looks like this:

type StyleHost = ShadowRoot | HTMLHeadElement;
type Style = { source: string };

/**
* Motive CSS Injector to add styles to custom host. This injector is used
* to add Motive's styles but also for third-party dependencies such as
* X-Components.
*/
class MotiveCSSInjector {
private queue: Style[] = [];
private host: StyleHost | null = null;


/**
* Sets the host element and adds the enqueued styles to the given host.
* @param host - The host element.
*/
setHost(host: StyleHost): void {
this.host = host;
this.queue.forEach(style => this.addStyle(style));
this.queue = [];
}


/**
* Adds style to the host element or enqueues it to be added when a host
* element is set.
* @param style - The object with source.
*/
addStyle(style: Style): void {
if (!this.host) {
this.queue.push(style);
return;
}

const styleElement = document.createElement('style');
styleElement.textContent = style.source;
this.host.appendChild(styleElement);
}
}

declare global {
interface Window {
motiveCSSInjector: MotiveCSSInjector;
}
}

window.motiveCSSInjector = new MotiveCSSInjector();
export {};

This injector returns two functions on window: setHost and addStyle, which then we use to set the host of the search layer and add CSS styles in the build process.

After that, it’s time to create the DOM subtree where the search layer will be located, depending on the value of the isolated flag. We will take this opportunity to tell the CSS injector, which is our already created host.

/**
* Create a DOM element where Vue application will be mounted.
* If "isolated" setting is true, a shadowRoot will be created.
* It also sets the host element where the styles will be injected.
* @param isolated - True if under a Shadow DOM context, false in the
* regular DOM.
* @returns Element on which to mount the application.
*/
function getDomElement(isolated: boolean): Element {
const domElement = document.createElement('div');
if (isolated) {
const container = document.createElement('div');
const shadowRoot = container.attachShadow({ mode: 'open' });
shadowRoot.appendChild(domElement);
document.body.appendChild(container);
window.motiveCSSInjector.setHost(shadowRoot);
} else {
document.body.appendChild(domElement);
window.motiveCSSInjector.setHost(document.head);
}
return domElement;
}

Finally, we have to tell the style injector of the build process to use our CSS injector to locate the search layer CSS in the Shadow DOM context or in the document.head as regular DOM.

import styles from "rollup-plugin-styles";

return {
...,
plugins: [
styles({
mode: ['inject', varname =>
`window.motiveCSSInjector.addStyle({ source: ${varname} });`]
})
]
};

Using Shadow DOM then allows us to isolate the CSS of our search layer component from the CSS of the main document, preventing conflicts and ensuring that our search layer component always looks and functions as intended.

What we learnt in the process

Using Shadow DOM to encapsulate CSS styles in this way helps us prevent conflicts and unintended side effects that can occur when different parts of a web page have conflicting styles. This is particularly important in large web applications like eCommerce shops, where there are many different components that need to be styled separately.

In addition to encapsulating styles, the Shadow DOM capabilities also include encapsulation for JavaScript and HTML, helping developers create truly modular, reusable components that can be used across different parts of a web application without worrying about conflicting styles, scripts, or HTML.

Overall, the Shadow DOM provides powerful ways to encapsulate components helping us make our web development more modular, maintainable, and scalable. Let’s embrace the new web capabilities and make websites a place where different code sources can coexist in peace!

--

--

Jose A. Cabaneros
Motive.co

Software and Web engineer. Frontend & Data Visualisation engineer at motive.co & empathy.co. IoT lover and practitioner.