Web Standards Meet User-Land: Using CSS-in-JS to Style Custom Elements

Avatar of Ollie Williams
Ollie Williams on (Updated on )

The popularity of CSS-in-JS has mostly come from the React community, and indeed many CSS-in-JS libraries are React-specific. However, Emotion, the most popular library in terms of npm downloads, is framework agnostic.

Using the shadow DOM is common when creating custom elements, but there’s no requirement to do so. Not all use cases require that level of encapsulation. While it’s also possible to style custom elements with CSS in a regular stylesheet, we’re going to look at using Emotion.

We start with an install:

npm i emotion

Emotion offers the css function:

import {css} from 'emotion';

css is a tagged template literal. It accepts standard CSS syntax but adds support for Sass-style nesting.

const buttonStyles = css`
  color: white;
  font-size: 16px;
  background-color: blue;

  &:hover {
    background-color: purple;
  }
`

Once some styles have been defined, they need to be applied. Working with custom elements can be somewhat cumbersome. Libraries — like Stencil and LitElement — compile to web components, but offer a friendlier API than what we’d get right out of the box.

So, we’re going to define styles with Emotion and take advantage of both Stencil and LitElement to make working with web components a little easier.

Applying styles for Stencil

Stencil makes use of the bleeding-edge JavaScript decorators feature. An @Component decorator is used to provide metadata about the component. By default, Stencil won’t use shadow DOM, but I like to be explicit by setting shadow: false inside the @Component decorator:

@Component({
  tag: 'fancy-button',
  shadow: false
})

Stencil uses JSX, so the styles are applied with a curly bracket ({}) syntax:

export class Button {
  render() {
    return <div><button class={buttonStyles}><slot/></button></div>
  }
}

Here’s how a simple example component would look in Stencil:

import { css, injectGlobal } from 'emotion';
import {Component} from '@stencil/core';

const buttonStyles = css`
  color: white;
  font-size: 16px;
  background-color: blue;
  &:hover {
    background-color: purple;
  }
`
@Component({
  tag: 'fancy-button',
  shadow: false
})
export class Button {
  render() {
    return <div><button class={buttonStyles}><slot/></button></div>
  }
}

Applying styles for LitElement

LitElement, on the other hand, use shadow DOM by default. When creating a custom element with LitElement, the LitElement class is extended. LitElement has a createRenderRoot() method, which creates and opens a shadow DOM:

createRenderRoot()  {
  return this.attachShadow({mode: 'open'});
}

Don’t want to make use of shadow DOM? That requires re-implementing this method inside the component class:

class Button extends LitElement {
  createRenderRoot() {
      return this;
  }
}

Inside the render function, we can reference the styles we defined using a template literal:

render() {
  return html`<button class=${buttonStyles}>hello world!</button>`
}

It’s worth noting that when using LitElement, we can only use a slot element when also using shadow DOM (Stencil does not have this problem).

Put together, we end up with:

import {LitElement, html} from 'lit-element';
import {css, injectGlobal} from 'emotion';
const buttonStyles = css`
  color: white;
  font-size: 16px;
  background-color: blue;
  &:hover {
    background-color: purple;
  }
`

class Button extends LitElement {
  createRenderRoot() {
    return this;
  }
  render() {
    return html`<button class=${buttonStyles}>hello world!</button>`
  }
}

customElements.define('fancy-button', Button);

Understanding Emotion

We don’t have to stress over naming our button — a random class name will be generated by Emotion.

We could make use of CSS nesting and attach a class only to a parent element. Alternatively, we can define styles as separate tagged template literals:

const styles = {
  heading: css`
    font-size: 24px;
  `,
  para: css`
    color: pink;
  `
} 

And then apply them separately to different HTML elements (this example uses JSX):

render() {
  return <div>
    <h2 class={styles.heading}>lorem ipsum</h2>
    <p class={styles.para}>lorem ipsum</p>
  </div>
}

Styling the container

So far, we’ve styled the inner contents of the custom element. To style the container itself, we need another import from Emotion.

import {css, injectGlobal} from 'emotion';

injectGlobal injects styles into the “global scope” (like writing regular CSS in a traditional stylesheet — rather than generating a random class name). Custom elements are display: inline by default (a somewhat odd decision from spec authors). In almost all cases, I change this default with a style applied to all instances of the component. Below are the buttonStyles which is how we can change that up, making use of injectGlobal:

injectGlobal`
fancy-button {
  display: block;
}
`

Why not just use shadow DOM?

If a component could end up in any codebase, then shadow DOM may well be a good option. It’s ideal for third party widgets — any CSS that’s applied to the page simply won’t break the component, thanks to the isolated nature of shadow DOM. That’s why it’s used by Twitter embeds, to take one example. However, the vast majority of us make components for for a particular site or app and nowhere else. In that situation, shadow DOM can arguably add complexity with limited benefit.