DEV Community

Westbrook Johnson
Westbrook Johnson

Posted on • Updated on

Why Would Anyone Use Constructible Stylesheets, Anyways?

!! DISCLAIMER: The following article contains examples that rely on APIs that are no longer so new and are no1 supported in both Chrome and Firefox (edit: 7/6/22). Please refer to their Chrome Status page for information on that support. !!

Indeed, why?

I mean it, I'm working on figuring out a solid answer for it myself, and by reading this article you've unwittingly volunteered to support me in doing so. Ready?

What are Constructible Stylesheets

Oh, you've not seen or used Constructible Stylesheets before? Well, that's not surprising, they're pretty new. From here on out there will be code samples and demos for you to play with, please take into consideration the current level of support outlined in that Chrome Status page if you'd like to see that code live in the browser. Beyond that, here is a great primer if you'd like to read ahead, it might also go a long way in supporting the conversation I hope to spark herein for everyone to have a little extra knowledge.

Very generally, the API works as follows...

    const myStyleSheet = new CSSStyleSheet();
Enter fullscreen mode Exit fullscreen mode

At its most simple, the above is a Constructible Stylesheet. The new keyword has been used to construct a CSSStyleSheet. Once you've constructed said style sheet, you are also given two new methods on the sheet in question. First, the replaceSync method, which allows for the synchronous replacement of the styles described by the sheet:

    myStyleSheet.replaceSync('h1 { color: green; }');
Enter fullscreen mode Exit fullscreen mode

And second, the replace method, which again allows you to replace all of the styles in the sheet, however with the added ability to use external resources via @import statements rather than just static style strings:

    myStyleSheet.replace('@import url("styles.css")');
Enter fullscreen mode Exit fullscreen mode

The later returns a promise that allows you to handle the success or failure of that load. You also continue to have access to the full CSSStyleSheet object and it's more granular manipulation methods like deleteRule and insertRule, as well as access to the cssRules array to manipulate specific CSSRule entries therein. Once you have your style sheet available in the javascript scope, what good it is to you? Not much, that is not until a document or document-fragment "adopt" that style sheet.

document.adoptedStyleSheets

In that the most general part of a web page is the document, let's start there! Take a look at Constructible Stylesheets in action via the adoptedStyleSheets API on the document below:

Now, before you switch over to the code view above, let's take a quick remembering of how this might be done without Constructible Stylesheets. Roughly in order from least awesome:

  • constantly appending a new <link rel="stylesheet" /> or <style /> to the <head/>
  • managing inline styles via the element's style attribute
  • toggling the class/id/other significant selector of the element
  • programmatically managing the rules of a <style/> tag
  • CSS custom properties

It may read a bit like the answer in a coding interview. First, we could brute force new styles into the page for every change. You might stop there, but then you think about what it might look like to be a little more direct, so you just write the changes directly into the element at hand. This works great in this one context but doesn't scale very well for styles that apply to more than one element or a wider number of styles. For scale, you pursue a path of least resistance and gate the styles behind a master class/id. This gives you a single pivot point to the various styles, as well as the ability to manage several different rules, however, it also means you don't get very fine-grained control over what styles you turn on/off or change without managing a lot more gates.

If you're gonna manage more gates, why reach into the DOM to do so, move those gates up into an actual <style/> tag and manage rules directly via style.sheet.addRule/deleteRule it means you have to ship all of the different style variants somewhere, bloating your over-the-wire costs, but you do get a lot of scalar and granular performance approaching the situation in this way. Similarly, you could move those gates into your CSS via custom properties and a switch on element.style.setProperty(propertyName, value), this is pretty promising in the way that it flows through your application and adheres to the cascade, however when managing a lot of different properties this can also be difficult to manage.

Yes, we have all of these approaches to changing styles in an application and none of them perfect, so we were given another, document.adoptedStyleSheets = [...], and that's what you'll see in the above editor view. Via this API you can compose an array of CSSStyleSheets for adoption by a document or document fragment. And, right now is a great time to ask "why would anyone use that?"

Why, indeed. At the document level, this API is likely doing little more than offering more options in a crowded field of options where you need to accurately weigh trade-offs in the face of your specific goals to make a decision rather than submitting a technique that can stand head and shoulders above others. There's certainly room to look into how this might give a solid bump to time tested approaches like webpack powered CSS Modules, not standards tracked CSS Modules, that specifically add a large number of <style/> elements into the <head/> when injecting themselves into an app. CSS-in-JS libraries like Emotion and Styled Components are already editing styles via use of style.sheet/insertRule et al, it would be hard from the outside to guess where or how they would benefit from an even deeper integration with the platform here, but I'd like to think there's some small win for these approaches via this API. If you use these sorts of tools extensively and could see some of those wins, or if you use other tools that you could see value in these approaches, I hope you share some of the options you see opening up to you with these features in the comments below!

However, where this API starts to earn its supper is when applying it to elements using Shadow DOM. In doing so you both have the ability and a growing need to apply a single style sheet multiple times across a single document.

shadowRoot.adoptedStyleSheets

Not only is this really where the specification was originally targeted, but this is where it starts to get cool... Before we get into it, here's a quick primer on Shadow DOM for those who might not use it every day.

    function createShadow(el) {
        const shadowRoot = el.attachShadow({ mode: "open" });
        shadowRoot.innerHTML = `
            <style>
                h1 {
                    color: red;
                    size: 3em;
                }
            </style>
            <h1>This is in a Shadow Root</h1>
        `;
    }
Enter fullscreen mode Exit fullscreen mode

This code attaches a shadow root to the supplied el and then innerHTMLs some content and styles. Looks pretty straight forward, however in between the lines of JS something magical happened, we encapsulated the applied content and styles away from the rest of the document in a document fragment that protects it from prying selectors (both CSS and JS) and the rest of the document from its styles. What's more, in our new shadowRoot variable we've created another location on which the adoptedStyleSheets API is available.

Sharing

Now, imagine that you're attaching the above shadow root to a custom element and suppose you want to put tens, or hundreds, of that custom element into your content. You're reusing code, you're encapsulating it from the rest of your page, you're feeling good about your page performance until you realize that you're now creating a new (and theoretically unique, though some browsers will work behind the scenes to address this for you) style sheet for each one of those elements. With just one style like our example, you might be able to swallow that parsing cost, but imagine this concept intersected with the last style sheet you worked with before reading this article and it's likely you start to see the costs piling up. This is where the fact that our page now has not just one or two locations where the adoptedStyleSheets API is available, but one for each instance of the custom element you've created starts to come into play.

    const sheet = new CSSStyleSheet();
    sheet.replaceSync(`
        h1 {
            color: red;
            size: 3em;
        }
    `);

    function createShadow(el) {
        const shadowRoot = el.attachShadow({ mode: "open" });
        shadowRoot.innerHTML = `
            <h1>This is in a Shadow Root</h1>
        `;
        shadowRoot.adoptedStyleSheets = [sheet];
    }
Enter fullscreen mode Exit fullscreen mode

Being lazy

Staying with the assumption that this is being shipped to the page via custom elements we can take the structure of this code one step further. Currently, this example is only reaping the benefits of sharing the style sheet between the myriad of instances of our custom element, however in the context of the main example from the Constructible Stylesheets proposal we can also leverage the possibility that the custom element in question isn't available to the DOM on page load to lazily parse the styles from the shared sheet:

    const myElementSheet = new CSSStyleSheet();
    class MyElement extends HTMLElement {
        constructor() {
            super();
            const shadowRoot = this.attachShadow({ mode: "open" });
            shadowRoot.adoptedStyleSheets = [myElementSheet];
        }

        connectedCallback() {
            // Only actually parse the stylesheet when the first instance is connected.
            if (myElementSheet.cssRules.length == 0) {
                myElementSheet.replaceSync(styleText);
            }
        }
    }
Enter fullscreen mode Exit fullscreen mode

Composing

While sharing out styles across elements and managing the parse time of those styles Constructible Stylesheets also enables style composition via adoptedStyleSheets = [...sheets]. One of the main benefits of working with Shadow DOM and the encapsulation that it provides is the return to small documents. It is arguable that the central issue at hand when the community calls out the difficulties of working with CSS in a modern context is the fact that when it was created the documents we worked on were just that much smaller; small document, small style sheet, small amount of work to manage their relationship. Shadow DOM goes a long way to restore that, and now when mixed with adoptedStyleSheets it can be taken to the next level.

Rather than addressing the application of a style system via CSS compilation where you might statically compose styles, a la:

    @import 'colors';
    @import 'headlines';
    @import 'layout';
    @import 'lists';
    // etc.
Enter fullscreen mode Exit fullscreen mode

And then applying those styles globally to your site via something like:

    <link rel="stylesheet" href="all-my-styles.css" />
Enter fullscreen mode Exit fullscreen mode

A custom element can now share and compose just the pieces of your style system into itself in a way that further alleviates the pains of dead CSS removal by making the following possible:

    import {
        colorsSheet,
        headlinesSheet,
        listsSheet,
    } from '/style-system.js';
    import {
        styles,
    } from './styles.js';

    // ...

    connectedCallback() {
            // Only compose styles once
            if (this.shadowRoot.adoptedStyleSheets.length == 0) {
                this.shadowRoot.adoptedStyleSheet = [
                    colorSheet,
                    headlinesSheet,
                    listsSheet,
                    styles,
                ];
            }
        }
Enter fullscreen mode Exit fullscreen mode

If you're component shops having lists in it, remove the listsSheet import and when none of the components in your build have lists the stylesheet will simply be tree shaken out of your build. This gets even nicer when native CSS Modules work their way through the standards process and we can start to rely on code like:

    import styles from './styles.css';

    const sheet = new CSSStyleSheet();
    sheet.replace(styles);
Enter fullscreen mode Exit fullscreen mode

This addresses an important reality that I've mostly avoided so far in this article. This is a JS API and that means that we're talking about working with our styles in strings. Without something like native CSS modules to allow our code direct access to styles in actual CSS files as a string, without special processing at runtime or [at build time], then these styles will need to live in JS. You can hide behind the fact that you're not modifying those styles (though you certainly could) to say that this process is not CSS-in-JS. However, this is predominately a JS API for managing CSS, so one would be in their right to call the differentiation here a technicality. Either way, the ergonomics of Constructible Stylesheets leave you wanting in a world where they can't be successfully paired with CSS Modules. Here's hoping that the success of JSON Modules at the specification level can reignite progress with the idea in the realm of CSS.

So, why?

Now that we all know more about how to use Constructible Stylesheets and what sort of things they make possible, the question still is "why would anyone use them?". Or, maybe it's, "why would you use them?" Hopefully, through all of the introductions, possibilities, and techniques discussed above YOU have started to get a feeling for what they might make available in your work. If so, I wanna hear about it in the comments below. Here's a recap of the benefits we've discussed above to get the conversation started:

  • style sharing for performance, less is more in performance and, depending on your current style application technique, one adoptedStyleSheets interface could save you tens or hundreds of <style/> elements regardless of whether you use Shadow DOM or not.
  • parsing and applying styles lazily allow for a level of control we've not had the opportunity to leverage in a componentized environment
  • style composition allows for a more precise application of styles, as well as the same sort of precision when removing styles which means it will be easier than ever to ensure you're only shipping exactly what's needed to your users at any one time
  • and, more...

That's right, there's more, some great reasons to use this API have started to make their way onto the scene in the form of great blog posts, libraries, and spec proposals. They're all worth checking out, but I've collected a sampling of them below.

Style system application

In his article Adopt a Design System inside your Web Components with Constructable Stylesheets, Ben Ferrel discusses how to take a pre-existing style system and apply it to web components without having to rewrite it for that context. Here he's done so within the confines of Adobe's Spectrum UI system, but the work is a solid proof of concept as to how you would do the same for the likes of Bootstrap or Tailwind CSS. Applying these systems within the Shadow DOM being an early blocker to engineers as they begin down the path to discovering the role that custom elements play in modern development, this pattern could open the door to web component usage in an even broader array of contexts. In his article, Ben even does a quick review of the possibilities in polyfilling the adoptedStyleSheets API in browsers that already support Shadow DOM natively.

Standard library elements

The possibility of actually expanding the standard library of HTML elements available to developers was one of the most exciting concepts when I was first introduced to the web components specifications. As they've solidified and support for them continues to grow, this dream is finally starting to become a reality. With the WHATWG is opening the door to opt-in HTML element expansions at a deeply integrated level. One of my favorite features of the proposal is --std-control-theme a CSS custom property that when read by JS conditionally adopts the style sheet that describes that theme. It's an impressive use of declarative styling at the natural intersection of HTML, CSS, and JS that I hope to see more of in the future.

Flexible base classes

I first learned about Constructible Stylesheets as part of the API provided by LitElement web component base class. When relying on its static get styles accessor, LitElement applies a bit of graceful degradation to allow for the use of Constructible Stylesheets when available. The functionality is structured to make style composition in the already scoped CSS context of Shadow DOM both easier and even more performant and is a clear win for the specification. LitElement and its use of Constructible Stylesheets are both cool subjects deserving of extended coverage.

What's next?

As Constructible Stylesheets are still so new, we as a technology community have only just begun to scratch the surface as to what might be possible when using them. Even in this article which started in search of "why" someone might use them I've asked more questions that I've answered myself. I mentioned my introduction to the concept via the LitElement base class, and I'd like to write more about what that looks like, as well as its limitations. On top of that, I look forward to sharing some things that I think will be possible as certain limitations therein (IE11/pre-Edgeium Edge support) are lifted from the workflow. To that end, I'd like to leave you with the following demo:

The above expands on the idea that you can have more than one of the same custom element each with a style application specific to itself by allowing the featured custom element to resolve those dependencies in the context of the application that it is in. In a very similar vein, I see the possibility for a custom element to take a different relationship to applying styles to its light DOM content. Look for these ideas and more to be discussed in greater depth alongside a less contrived example soon!

Latest comments (2)

Collapse
 
youngelpaso profile image
Jesse Sutherland

Great primer on adopting stylesheets!

Collapse
 
westbrook profile image
Westbrook Johnson

Hey look, someone finally did the research and found the 5-8x performance improvement that was hiding in this article...who knew? 😜

Potential 5x perf improvement for stylesheet updates by switching to `CSSStyleSheet.replaceSync` #2501

The problem

Currently, hot module reloading pages that use Emotion with React is about 8x slower than using CSSStyleSheet.replaceSync to update styles.

Here are two Chrome profiles where the same CSS is updated on disk 1024 times, sleeping for 32ms between each update. In the first case, it's a <Global> React component, and in the second case, it's a <link> tag being hot reloaded

with-emotion-react.json.gz - this one is using Emotion and the React Component is being re-imported each time. Note the difference in time spent on Rendering between the two profiles.

CleanShot 2021-10-07 at 23 35 22@2x

with-replaceSync.json.gz - this one is using CSSStyleSheet.replaceSync

CleanShot 2021-10-07 at 23 35 11@2x

This is the CSS:

:root {
  --timestamp: "16336741341477";
  --interval: "32";
  --progress-bar: 56.889%;
  --spinner-1-muted: rgb(179, 6, 202);
  --spinner-1-primary: rgb(224, 8, 253);
  --spinner-2-muted: rgb(22, 188, 124);
  --spinner-2-primary: rgb(27, 235, 155);
  --spinner-3-muted: rgb(89, 72, 0);
  --spinner-3-primary: rgb(111, 90, 0);
  --spinner-4-muted: rgb(18, 84, 202);
  --spinner-4-primary: rgb(23, 105, 253);
  --spinner-rotate: 304deg;
}
Enter fullscreen mode Exit fullscreen mode

This is the React component using Emotion:

import { Global } from "@emotion/react";
export function CSSInJSStyles() {
  return (
    <Global
      styles={`
:root {
  --timestamp: "16336721342556";
  --interval: "32";
  --progress-bar: 56.889%;
  --spinner-1-muted: rgb(179, 6, 202);
  --spinner-1-primary: rgb(224, 8, 253);
  --spinner-2-muted: rgb(22, 188, 124);
  --spinner-2-primary: rgb(27, 235, 155);
  --spinner-3-muted: rgb(89, 72, 0);
  --spinner-3-primary: rgb(111, 90, 0);
  --spinner-4-muted: rgb(18, 84, 202);
  --spinner-4-primary: rgb(23, 105, 253);
  --spinner-rotate: 304deg;
}  `}
    />
  );
}
Enter fullscreen mode Exit fullscreen mode

Proposed solution

Detect if CSSStyleSheet.replaceSync is supported in the current browser and use that to update the existing stylesheet (rather than creating a new one). This would work for both development and production (in production for dynamic styles).

Drawbacks:

  • This API is only available in Chromium browsers. Multiple "backends" for updating styles introduces complexity
  • @import is not supported with CSSStyleSheet.replaceSync

Alternative solutions

Additional context

replaceSync has some cost, but it's not so bad:

CleanShot 2021-10-07 at 23 58 24@2x

Versus:

CleanShot 2021-10-07 at 23 58 53@2x

Incase opening the profiles are a pain, here are screenshots.

Emotion: CleanShot 2021-10-08 at 00 09 54@2x

replaceSync: CleanShot 2021-10-08 at 00 08 16@2x