< Back
calendar

Nov 16, 2020

Smart Styling Of Web Components

How to leverage state-based styling the smart way

Header for posting Smart Styling Of Web Components
Photo by Scott Webb on Unsplash

One of the killer features of Web Components is true encapsulation of styling through Shadow DOM.

This means that the internal CSS of a Web Component can be completely isolated from the surrounding document without the need for a particular naming convention.

All styling defined inside the component is for the component only and doesn’t leak out of it and, except for inherited CSS properties, styles defined outside of the component don’t apply to it.

In practice this means that only properties that apply to text like font, color, line-height and text-align (among others) and visibility are inherited. All other styling doesn’t pierce through the Shadow DOM boundary and is defined inside Web Components.

A great benefit of this is that CSS can be greatly simplified since each stylesheet only applies to a single component, which eliminates the need for a large number of class names or naming conventions like BEM.

It’s like you only need to style mini-documents and don’t need to worry about affecting other documents.

Depending on the size of your Web Component you will usually only need a handful of HTML elements so your CSS will stay clear and concise.

State-based styling

While working with JSX in React and StencilJS components I noticed that the situation usually gets more complex when the internal state of a component changes and this needs to be reflected in its UI.

For example, when you have a button that has a disabled property that is set to true you will want to show the user it’s disabled, maybe by setting opacity to a lower value and cursor to not-allowed.

Usually this is accomplished by applying a conditional class:

render() {
return (
<div id="container" className={this.disabled ? 'disabled' : ''}>
<button>Save</button>
</div>
)
}

This is a simple example, but you can imagine things get hairy quickly when more states need to be reflected in the UI:

render() {
return (
<div id="container"
className={
`${this.disabled && 'disabled'}
${this.processing && 'processing'
${this.error && 'error'}
...
`}>
<button>Save</button>
</div>
)
}

Luckily, there are libraries available to make applying conditional classes easier but usually this still tends to get messy quickly.

Reflecting properties to attributes

Web Components provide an excellent solution for state-based styling by reflecting properties to attributes.

This means that each or only certain properties of a Web Component have a corresponding attribute which is updated whenever the value of the property changes.

This is already common practice for native HTML elements like <button> and <input> for example.

When you select a <button> on a page and set its disabled property to true you will notice it also gets a disabled attribute:

// before
<button>Edit</button> const button = document.querySelector('button');
button.disabled = true; // after
<button disabled>Edit</button>

We can leverage this mechanism combined with the :host() selector and CSS attribute selector to easily style Web Components depending on their state:

:host([disabled]) button {
opacity: 0.5;
cursor: not-allowed;
}

The :host() selector can take an arbitrary selector and in this case we use the attribute selector to define a style for when the button has a disabled attribute.

Since we reflect properties to attributes, we will usually use an attribute selector but of course we could also use other selectors like :host(.disabled) for example.

If it’s necessary to define styles that need to be applied only when multiple attributes are available then these can be simply specified at once:

:host([disabled][active]) {
...
}

The above rules will only be applied when both the disabled and active attributes are present.

We can also define styles for when an attribute has a specific value. For example, we may want to show a spinner inside the button when it has been clicked and an action is in progress, like the submitting of a form.

#spinner {
display: none;
} :host([state="processing"]) #spinner {
display: block;
} // inside the component:
<div id="container">
<button>
Saving...
<svg id="spinner"></svg>
</button>
</div>

Here, the spinner svg is hidden by default and when the button’s state property gets the value processing the spinner will be shown.

This way, we can easily show and hide elements, or make other changes to the UI, by simply changing certain properties of the Web Component.

It’s a simple and clean approach that has many benefits.

awesome-button[disabled] {
pointer-events: none; ...
} <awesome-button disabled></awesome-button>

How to reflect properties to attributes

Reflecting a property to an attribute is easily accomplished by defining a setter for the property. Whenever the value of the property changes, we change the attribute as well inside the setter:

set disabled(isDisabled) {
if(isDisabled) {
this.setAttribute('disabled', '');
}
else {
this.removeAttribute('disabled');
}
}

In this case, disabled is an empty attribute so we just set or remove the attribute depending on the value.

If, for example, we had a state property that can take specific values the attribute would take this value as well:

set state(value) {
this.setAttribute('state', value);
}

If we also want to be able to change the property through the attribute then we may be tempted to add it to the observedProperties of the Web Component and implement attributeChangedCallback() but this is a mistake since this will cause an infinite loop:

class AwesomeButton extends HTMLElement {  static get observedProperties() {
return ['disabled'];
} constructor() {
super(); ...
} set disabled(isDisabled) {
if(isDisabled) {
this.setAttribute('disabled', '');
}
else {
this.removeAttribute('disabled');
}
} // do NOT try to set the property inside the callback
// this will invoke the setter which in turn will invoke
// attributeChangedCallback which will invoke the setter again
// causing an infinite loop

attributeChangedCallback(attr, oldVal, newVal) {
if(attr === 'disabled') {
this.disabled = this.hasAttribute('disabled');
}
}
}

Instead, we provide a getter which will read the value from the attribute so they are always in sync:

get disabled() {
return this.hasAttribute('disabled');
}

State-based styling done right

When you start writing Web Components that leverage state-based styling through reflection of properties to attributes, you will find that both the HTML and CSS of your component will become more clean, concise and easier to understand.

You will define CSS rules on a higher level, starting from the host down, which will result in cleaner CSS with less rules and overrrides.

You will start with defining general rules that apply to certain properties:

// default styles
:host() {
...
} // disabled = true
:host([disabled]) {
...
} :host([disabled]) button {
...
} // state = 'processing'
:host([state="processing"]) {
...
} :host([state="processing"]) .spinner {
...
}

And when needed you can apply specific additional styles or overrides:

// style applied when both state = "processing" and active = true
:host([state="processing][active]) {
...
}

It will not always make sense to reflect certain properties to attributes so when this is not the case, it’s probably better to find another solution. Also, keep in mind that only properties that take primitive values like strings, numbers an booleans should be reflected.

It won’t make sense to reflect a property that takes an array of objects as a value for example, since this value will then need to be serialised and there is no CSS rule available to target it.

In such a case it’s better to reflect to another attribute that has a primitive value.

For example, if you have a <data-table> component that has a data property that takes an array of objects and you need styling when it’s set, you could reflect to an empty attribute populated to indicate the component has been given data:

// value is an array of objects
set data(value) {
if(value.length > 0) {
this.setAttribute('populated', '');
}
else {
this.removeAttribute('populated');
}
}

Have a look at my material-button Web Component which heavily uses reflection of properties to attributes for styling for inspiration.


Join Modern Web Weekly, my weekly update on the modern web platform, web components, and Progressive Web Apps delivered straight to your inbox.