Techniques Home ~ alanwsmith.com ~ other projects ~ socials

Handling Styles In Web Components

January 2025

Introduction

I have two main struggles with web component styling:

Both cases require messing with the parent page's CSS. That breaks my mental model of the encapsulation. This page looks at a self-contained approach.

Overview

I'm using two techniques to avoid having to adjust a component's parent page CSS:

  1. Use the :host selector in the component to set styles on the custom element itself
  2. Pass CSS variable names (or specific values) into the component via attributes

The only thing necessary after that is to set optional attributes inside the custom element itself.

The Component File

Here's a full example (which also powers the examples on this page):

class AlansWebComponent extends HTMLElement {
  constructor() {
    super()
    this.attachShadow({mode: 'open'})
  }

  addContent() {
    const template = 
      this.ownerDocument.createElement('template')
    template.innerHTML = `<div>Ping</div>`
    const contents = 
      template.content.cloneNode(true)
    this.shadowRoot.append(contents)
  }

  connectedCallback() {
    this.getAttributes()
    this.getColors()
    this.generateStyles()
    this.addContent()
  }

  generateStyles() {
    const styles = new CSSStyleSheet();
    styles.replaceSync(`
      :host { 
        display: inline-block;
        background: ${this.colors['bg-color']};
        color: ${this.colors['text-color']};
        padding: 0.3rem;
      }`
    );
    this.shadowRoot.adoptedStyleSheets.push(styles);
  }

  getAttributes() {
    this.attrs = {}
    const attrs = this.getAttributeNames()
    attrs.forEach((attr) => {
      if (attr.startsWith(':') === true) {
        this.attrs[attr.substring(1)] = 
          this.getAttribute(attr)
      }
    })
  }

  getColors() {
    this.colors = {
      'bg-color': 'blue',
      'text-color': 'inherit'
    }
    for (const color in this.colors) {
      if (this.attrs[color] !== undefined) {
        this.colors[color] = this.attrs[color]
      }
    }
  }
}

customElements.define('alans-wc', AlansWebComponent)

We'll dig into the details in a moment. First, here's a look at how it's used:

Basic Usage

The component's filename is component.js. It's included via this tag:

<script type="module" src="component.js"></script>

Example 1

The component provides a custom element named alans-wc that outputs the word "Ping" on top of a background color. The basic use looks like this:

Code

<alans-wc></alans-wc>

Result

The component defaults the background color to blue. The text color is inherited.

Example 2

Adding a :bg-color attribute to the custom element sets the color behind the text:

Code

<alans-wc
  :bg-color="red"
></alans-wc>

Result

Using CSS Variables

Even better than being able to pass explicit colors is the ability to pass CSS variables defined by the parent page's styles.

Example 3

This page includes the following in its CSS:

:root {
  --accent-color-1: rebeccapurple;
  --accent-color-2: goldenrod;
  --accent-color-3: maroon;
}

--accent-color-1 is what's used for the borders of the example code/result blocks. We can set a component to use the same variable for its background like this:

Code

<alans-wc
  :bg-color="var(--accent-color-1)"
></alans-wc>

Result

The name of the variable is passed into a function that builds the stylesheet for the component's shadowroot. Once there, it grabs the associated value from the parent page's styles like a regular CSS variable call.

Example 4

Component instances are isolated from each other. We can use different variables for each one:

Code

<alans-wc
  :bg-color="var(--accent-color-1)"
></alans-wc>

<alans-wc
  :bg-color="var(--accent-color-2)"
></alans-wc>

<alans-wc
  :bg-color="var(--accent-color-3)"
></alans-wc>

Result

Example 5

The same technique can be used with multiple styles. Here's an instance that uses a text-color in addition to the bg-color:

Code

<alans-wc
  :bg-color="var(--accent-color-2)"
  :text-color="var(--accent-color-1)"
></alans-wc>

<alans-wc
  :bg-color="var(--accent-color-3)"
  :text-color="var(--accent-color-2)"
></alans-wc>

<alans-wc
  :bg-color="var(--accent-color-1)"
  :text-color="var(--accent-color-3)"
></alans-wc>

Result

Looking At The Code

The component's connectedCallback() calls four functions: getAttributes(), getColors(), generateStyles(), and addContent().

  connectedCallback() {
    this.getAttributes()
    this.getColors()
    this.generateStyles()
    this.addContent()
  }

They do the following:

getAttributes()

This function slurps up all the attributes from the custom element.

  getAttributes() {
    this.attrs = {}
    const attrs = this.getAttributeNames()
    attrs.forEach((attr) => {
      if (attr.startsWith(':') === true) {
        this.attrs[attr.substring(1)] = 
          this.getAttribute(attr)
      }
    })
  }

It works by looping through every attribute and pulling out the ones that start with a (:) colon.

getColors()

The getColors() function works by creating a this.colors variable and pre-populating it with default colors. The next step is to loop through the attributes to see if any colors were set in the instance and update them if they were.

  getColors() {
    this.colors = {
      'bg-color': 'blue',
      'text-color': 'inherit'
    }
    for (const color in this.colors) {
      if (this.attrs[color] !== undefined) {
        this.colors[color] = this.attrs[color]
      }
    }
  }

generateStyles()

generateStyles() creates a new stylesheet using the this.color values and adds it to the shadowRoot.

The styles are applied via the :host selector. That what applies them to the <alans-wc> custom element itself.

  generateStyles() {
    const styles = new CSSStyleSheet();
    styles.replaceSync(`
      :host { 
        display: inline-block;
        background: ${this.colors['bg-color']};
        color: ${this.colors['text-color']};
        padding: 0.3rem;
      }`
    );
    this.shadowRoot.adoptedStyleSheets.push(styles);
  }

addContent()

Finally, the addContent() function adds the HTML for the component to the shadowRoot via a template.

  addContent() {
    const template = 
      this.ownerDocument.createElement('template')
    template.innerHTML = `<div>Ping</div>`
    const contents = 
      template.content.cloneNode(true)
    this.shadowRoot.append(contents)
  }

Conclusion

Styling web components was one of the major tripping blocks that led to false starts when I started using them. This approach lets me adjust their styles with a mental model that I can easily grok.


Endnotes

References