Techniques Home ~ alanwsmith.com ~ other projects ~ socials

Handling Styles In Web Components

January 2025

TL;DR

The number of examples in this post makes it kinda long. Here's the highlights:

The Component File section has a full code example if you'd rather just see code.

Introduction

One aspect of web components I struggle with is that they never feel self-contained. The main place I feel that friction is with styles. Specifically:

In both cases, using the component requires messing with the parent page's CSS. I really don't like the way that feels. I had a couple realizations about how to address those issues. This page is for taking a look at them.

Fair warning: I'm still new to web components. I may be off-base here. Part of the reason I'm doing this write up it to solicit feedback.

With that said, here's what I'm doing:

Overview

There are two techniques I'm using to address working with styles:

  1. Add styles to the parent page by creating a new element and appending it to the page via document.head from the component's .js file before defining the component itself
  2. Pass the names of CSS variables (or specific values) I want to use into the component via attributes in the custom element's tag

I can use a component on a page without having to adjust the parent's CSS at all when I use these methods. The only thing I have to touch is the component's custom element tag and its attributes.

The Component File

Here's a full example where I'm using the techniques. This is also the code that powers the examples on this page:

const componentStyles = document.createElement('style')
componentStyles.innerHTML = `
  aws-wc {
    display: inline-block;
  }
`
document.head.appendChild(componentStyles)

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

  connectedCallback() {
    this.getColors()
    this.shadowRoot.append(this.styles())
    const contents = 
      this.template().content.cloneNode(true)
    this.shadowRoot.append(contents)
  }

  getColors() {
    this.backgroundColor = 
      this.getAttribute('backgroundColor') 
      ? this.getAttribute('backgroundColor') 
      : 'blue'
    this.textColor = 
      this.getAttribute('textColor') 
      ? this.getAttribute('textColor') 
      : 'inherit'
  }

  template() {
    const template = 
      this.ownerDocument.createElement('template')
    template.innerHTML = `<div>Ping</div>`
    return template 
  }

  styles() {
    const styles = document.createElement('style')
    styles.innerHTML = `
      div {
        background: ${this.backgroundColor};
        border-radius: 0.4rem;
        color: ${this.textColor};
        padding: 0.3rem;
      }
    `
    return styles 
  }
}

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

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

Basic Usage

The component's filename is component.js. It's included via this tag that's placed just before the page's closing body tag:

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

Example 1

The component provides a custom element named aws-wc (where "aws" stands for alan w smith. Not the web services company) that outputs the word "Ping" with a background color behind it. The basic use looks like this:

Code

<aws-wc></aws-wc>

Result

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

Example 2

The component is designed to do very little. The idea being to keep the examples as clear as possible. One feature it does have is the ability to pass a color to use for the background via a "backgroundColor" attribute like this:

Code

<aws-wc
  backgroundColor="red"
></aws-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 stylesheets:

:root {
  --accent-color-1: purple;
  --accent-color-2: yellow;
  --accent-color-3: red;
}

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

Code

<aws-wc
  backgroundColor="var(--accent-color-1)"
></aws-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

<aws-wc
  backgroundColor="var(--accent-color-1)"
></aws-wc>

<aws-wc
  backgroundColor="var(--accent-color-2)"
></aws-wc>

<aws-wc
  backgroundColor="var(--accent-color-3)"
></aws-wc>

Result

Example 5

The same technique can be used with multiple styles. Here's an instance that uses a textColor in addition to the backgroundColor:

Code

<aws-wc
  backgroundColor="var(--accent-color-2)"
  textColor="var(--accent-color-1)"
></aws-wc>

<aws-wc
  backgroundColor="var(--accent-color-3)"
  textColor="var(--accent-color-2)"
></aws-wc>

<aws-wc
  backgroundColor="var(--accent-color-1)"
  textColor="var(--accent-color-3)"
></aws-wc>

Result

Adding Styles To The Parent Page

It's time to start looking at the component file itself.

Our aws-wc component is designed to work as a display: inline-block element. We have to set the style to avoid it taking up an entire row.

I originally thought this meant having to modify the parent page's CSS directly. That's not the case. We can do it in the same .js file that contains our component by adding a stylesheet outside the definition of the component's class.

Here's the top of the component.js file where we do just that:

const componentStyles = document.createElement('style')
componentStyles.innerHTML = `
  aws-wc {
    display: inline-block;
  }
`
document.head.appendChild(componentStyles)

This fires when the script is loaded on the page. It only fires once instead of each time an instance is created because it's outside the component's class definition.

Passing Style Variables To The Component

Using The ShadowDOM

The component is set up to use the shadowDOM with the this.attachShadow({mode: 'open'}) call in the constructor:

  constructor() {
    super()
    this.attachShadow({mode: 'open'})
  }

Getting The Attribute Values

Pulling style variables (or, explicit values) in from a custom tag's attributes takes a few steps. The first thing is to add a function to the component's connectedCallback() to grab the values from the attributes. Here's the code where I'm calling a this.getColors() function:

  connectedCallback() {
    this.getColors()
    this.shadowRoot.append(this.styles())
    const contents = 
      this.template().content.cloneNode(true)
    this.shadowRoot.append(contents)
  }

The function looks like this:

  getColors() {
    this.backgroundColor = 
      this.getAttribute('backgroundColor') 
      ? this.getAttribute('backgroundColor') 
      : 'blue'
    this.textColor = 
      this.getAttribute('textColor') 
      ? this.getAttribute('textColor') 
      : 'inherit'
  }

The code checks to see if the color attributes exist in the custom element. They get used if they do. Otherwise, a default fallback gets set.

Applying The Styles

The internal stylesheet for the component is generated by the styles() function:

  styles() {
    const styles = document.createElement('style')
    styles.innerHTML = `
      div {
        background: ${this.backgroundColor};
        border-radius: 0.4rem;
        color: ${this.textColor};
        padding: 0.3rem;
      }
    `
    return styles 

It uses the this.backgroundColor and this.textColor variables defined by the getColors() function. They are written in as strings which is what allows them to accept CSS variable names.

There's no difference to the component compared to hard coding the variable names.

The styles are added to the shadowroot from the connectedCallback() function with:

    this.shadowRoot.append(this.styles())

Adding The Content

The body of the component is defined in the template() function:

  template() {
    const template = 
      this.ownerDocument.createElement('template')
    template.innerHTML = `<div>Ping</div>`
    return template 
  }

The connectedCallback() function adds the template to the shadowRoot with:

    const contents = 
      this.template().content.cloneNode(true)
    this.shadowRoot.append(contents)

Finishing The Component

The last step is to add the component's definition to the page with:

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

Conclusion

Using these techniques provides a way to make components that don't require messing with a parent page's stylesheet. It's a much nicer separation of concerns. It's made me genuinely more excited about making components.


Endnotes

TODO