DEV Community

Cover image for Web Component shadow roots x design system. Constructable style sheets?
Bryan Ollendyke
Bryan Ollendyke

Posted on

Web Component shadow roots x design system. Constructable style sheets?

Recently I fielded a question issue from a student about how we handle "global styles" when working in the HAXcms theme layer. This question is a common one I've found across developers of all experience levels due to the nature of two seemingly conflicting concepts:

  • CSS. Cascading, Style, Sheets.
  • Design systems leveraging the above
  • ShadowRoot - a controversial aspect of web components that breaks the cascade to allow locally scoped styles

As HAX project lead, our platform works exclusively through the building and nesting of 1,000s of Web Components and most (~95%) use ShadowRoots via Lit / LitElement.

How we manage local scoping vs global scoping

Constructable style sheets is an API that allows for not just treating the style sheet as text which is then leveraged as text in a <style> tag to be applied as CSS; it treats the style sheet as a full JavaScript class / object.

This allows for programmatically producing and sharing the style sheet around as a singular object. Lit / Google have some great articles about this so I'll link to these for background reading / more code detail:

So what does it look like?

In HAX, we have a designs system called Development, Design, Destroy (DDD). This design system needs to:

  • Ensure that individual web components / blocks look like DDD
  • Ensure our editing environment (h-a-x, a shadowRoot controlled authoring experience) looks like DDD
  • Ensure that our theme layer looks like DDD
  • Ensure the top level document looks like DDD (so things in the LightDom / entry file / HTML document)

Styles

We have all of our styles as exported variables from a single file. These styles are decorated via Lit's css styling based template literate tag. You can see a sample of one here as well as the source.

DDDStyles.js

Full file, code sample below

export const DDDVariables = css`
  :root {
    color-scheme: light dark;
  }
  :root,
  html,
  body,
  :host {
    /* base colors */
    --ddd-theme-default-beaverBlue: #1e407c;
    --ddd-theme-default-beaver70: rgba(30, 64, 124, 0.7);
    --ddd-theme-default-beaver80: rgba(30, 64, 124, 0.8);
    --ddd-theme-default-landgrantBrown: #6a3028;
}
`;
Enter fullscreen mode Exit fullscreen mode

We then take this variable and all other style decorated variables, export them but then also combine them into an Array to be able to ingest the entire library. This allows for DX downstream of adopting a portion of DDD or the entire thing. If an element only needs access to our borders or font sizes, then it can leverage just DDDBorders or DDDFontSizing to obtain just these references.

Code sample of the Array export

// export that has all of them for easy stamping as a single sheet
export const DDDAllStyles = [
  DDDVariables,
  ...DDDDataAttributes,
  DDDReset,
  DDDBreadcrumb,
  DDDExtra,
  DDDBorders,
  DDDMarginPadding,
  DDDLetterSpacing,
  DDDLineHeight,
  DDDBoxShadow,
  DDDBorderRadius,
  DDDBackground,
  DDDFontClasses,
  DDDFontWeight,
  DDDFontSizing,
  DDDAnimations,
];
Enter fullscreen mode Exit fullscreen mode

Note the comment at the top of the code block; we now have all of these style tokens in one Array so that we can build a style sheet that has all of them!

Individual element

DDD then ships with two ways we can implement it in individual web components. Method one gets everything + our legacy color system (called Simple Colors) + LitElement. This is common for when we go "ya it's going to need everything" or especially when augmenting work that pre-dates DDD (so it used our old Color library, allowing us to transition).

Method two, just applies the Reset via a SuperClass / mix-in approach. You'll note it also does a "is this awesome thing your making needing to be presented in the terrible things called Safari" ;)

Right with that though is this line:

globalThis.DDDSharedStyles.requestAvailability();
Enter fullscreen mode Exit fullscreen mode

Global document / html / Constructable Style Sheet

This requestAvailability() approach is how we handle Singleton Paradigms, especially important in an unbundled worldview. Singleton is 10 things need access to 1 thing, you don't know when they'll load, make a method that bridges that only one copy resides in the entire system regardless.

You can see the full call below which I'll then step through what it's doing:
Code in question

globalThis.DDDSharedStyles.requestAvailability = () => {
  if (
    globalThis.DDDSharedStyles.instance == null &&
    globalThis.document &&
    globalThis.document.head
  ) {
    // convert css into text content of arrays mashed together
    // this way we can inject it into a global style sheet
    let globalStyles = DDDAllStyles.map((st) =>
      st.cssText ? st.cssText : "",
    ).join("");
    try {
      const adoptableDDD = new CSSStyleSheet();
      adoptableDDD.replaceSync(globalStyles);
      // THIS FLAG MAKES HAX LOAD IT IN ITS SHADOW ROOT!!!!
      adoptableDDD.hax = true;
      // Combine the existing adopted sheets if we need to but these will work everywhere
      // and are very fast
      globalThis.document.adoptedStyleSheets = [
        ...globalThis.document.adoptedStyleSheets,
        adoptableDDD,
      ];
      loadDDDFonts();
      globalThis.document.onload = dddCSSFeatureDetection();
      globalThis.DDDSharedStyles.instance = adoptableDDD;
    } catch (e) {
      const oldStyleSafariBs = globalThis.document.createElement("style");
      oldStyleSafariBs.innerHTML = globalStyles;
      globalThis.document.head.appendChild(oldStyleSafariBs);
      loadDDDFonts();
      globalThis.document.onload = dddCSSFeatureDetection();
      globalThis.DDDSharedStyles.instance = oldStyleSafariBs;
    }
  }
  return globalThis.DDDSharedStyles.instance;
};
// self-appending on call
export const DDDSharedStylesGlobal =
  globalThis.DDDSharedStyles.requestAvailability();
Enter fullscreen mode Exit fullscreen mode

Steps / checks / happenings:

  1. Do we already exist
  2. Look at all the styles, .map the Array and join together all the cssText as a single string
  3. Use a CSSStyleSheet() and add it to the document's adoptedStyleSheets array, ensuring that DDD is applied last
  4. load fonts which get added as link's appended to <head>

You'll also note the "fun" Safari check to fallback and create a singular style element. Whis should probably say "older browsers" but I digress in my trolling.

Why do any of this?

Performance. performance. performance! While what we are doing operates like this:

  • 1 sheet for the document made out of the textual representation of the other style objects
  • 1 object reference across n number of elements to the same object in LitElement based shadowRoots

It used to work like this prior to Lit and our own adoption of this methodology:

  • css is text
  • apply that css as text across n number of elements

In other words, this started to completely brick the performance of our ecosystem. Constructable stylesheets ensured that if there's a CSS variable called --ddd-spacing-12 that it at most is in memory 2x (1 for the global document, 1 across all elements that use DDD).

In fact if you notice we put this in the global document which gives us 1 copy of the CSS variables, and then the default behavior of DDDSuper is simply to apply a CSS reset. This implies none of the variables are loaded directly into these elements (even in memory) and thus will take on the css variable values from their global application.

We are certainly not perfect in our usage of these things, but we've had great success with this approach. Our ecosystem of 658 web components loads with a high level of performance thanks to lit, the approaches mentioned here, and sweat equity.

HAX

If you'd like to learn more about the HAX ecosystem and how Penn State is building a Ubiquitous Web Authoring platform hit up the links below:

Top comments (0)