We’ve been writing a lot of web components recently. Etch has been embracing the modern web and questioning our reliance on lumbering JavaScript frameworks. Web components feel like they might reduce a lot of our need for creating components in React, Angular or Vue.
We’ve been styling components using BEM for a long time. If you’re not familiar with it, BEM is a way of formatting class names in HTML and CSS to clearly explain what that class name is targeting and its function.
Blocks, elements and modifiers
If you’d like to learn more about BEM for styling, head over to the BEM methodology site.
tl;dr BEM separates class names into: Block, Element and Modifier.
The Block
is the component wrapper, the top parent of your component, like a card
.
An Element
is an object within the component, such as the card__body
.
A Modifier
is a variation of a block, or element, such as card--rounded
.
The formatting for BEM has settled into a fairly common standard of a Block being the first word in the class, an Element being prefixed with two underscores and a modifier being prefixed with two hyphens. .block__element--modifier
. This has not always been the case and BEM did not originally dictate this naming convention, only that you must have a naming convention to separate your blocks, elements, and modifiers.
BEM is great because:
- It namespaces your CSS, preventing accidentally clashing with other CSS on the page.
- It makes it instantly clear what the intention of a class is: is it styling a component, a piece of a component, or changing that component’s styles?
- It is easy to write using nested styles in SCSS with minimal extra characters cluttering the text editor.
BEM Spanners
As always, something new comes along and challenges the way you’ve been doing things.
Enter - web components.
We’ve been wrapping up our components for usage by utilising JavaScript frameworks, such as React, Angular, or Vue. This means that, rather than having to write out a whole load of clunky HTML to use our components, we can call the component instead, which looks much neater.
<div class="card"><div class="card__body">[...content...]</div></div>vs<Card>[...content...]</Card>
Web components bring the power of creating components to the native web, so we don’t need a giant JavaScript framework to let us create encapsulated bits of UI.
<etch-card>[...content...]</etch-card>
However, due to the way web components work, they don’t feel like they fit with a traditional BEM class styling approach.
Web components can be styled in 2 ways. From from the shadow DOM, using :host
and class
selectors, or from the light DOM, using element
and ::part
selectors.
What the heck is shadow DOM? If that sounds more like ninjas than nerds, read up about shadow DOM on MDN.
At this point, you either know a bit about shadow DOM, or you need to go back and read those docs above. Shadow DOM is encapsulated from a CSS point of view, styles do not penetrate in, or leak out (except in some very specific cases).
A web component has a Host
element, this is the top-level tag for the component and what we would normally see as the Block
in BEM. In our example above, this is the etch-card
element.
Inside the component shadow DOM are regular HTML elements and even other web components. These are our Elements
and they can either be accessed in CSS using a class
attribute or a part
attribute.
Just like in React, Angular or Vue, props are passed to web components via attributes on the Host
element, for example <etch-card rounded>
. The props can be used to change what is rendered in the shadow DOM.
Let’s look at the 2 styling options for web components and how we could apply traditionally formatted BEM to them.
:host and classes
Web components can contain their own CSS, applied to the element on render. If we used traditional BEM, we could render an element inside the shadow DOM to use at the Block
element, or add a class to the Host
element. Here’s an example in lit, a popular web component framework.
class MyElement extends LitElement {static properties = {class: { reflect: true }}connectedCallback () {super.connectedCallback()this.classList.add("my-element")}willUpdate (changedProperties) {if (changedProperties.has("class")) {this.classList.add("my-element");}}}
This is pretty verbose for just adding a class to the base element.
To style this base class, you would think we could access it like a normal class.
static styles = css`.my-element {color: green;}`;
Unfortunately, due to the way style encapsulation works in shadow DOM, this class selector will not apply to the Host
element, it would only apply if it was a child element within the shadow DOM. To apply to the host element, we have to wrap it in a :host-context
selector.
static styles = css`:host-context(.my-element) {color: green;}`;
Styling Elements
is a little simpler as we can access these from the component CSS using normal class selectors.
static styles = css`:host-context(.my-element) {color: green;}.my-element__body {background: blue;}`;
To add a modifier, we would listen to a prop and use that to render a Modifier
class on the host element.
class MyElement extends LitElement {static properties = {class: { reflect: true }}connectedCallback () {super.connectedCallback()this.classList.add("my-element");if (this.variant === '2') {this.classList.add("my-element--variant2");}}willUpdate (changedProperties) {if (changedProperties.has("class")) {this.classList.add("my-element");if (this.variant === '2') {this.classList.add("my-element--variant2");}}}}
Once we’ve added these Elements
and Modifiers
, you can quickly see how inconsistent and messy the styles look; there’s is no uniformity to their appearance.
static styles = css`:host-context(.my-element) {color: green;}.my-element__body {background: blue;}:host-context(.my-element--variant2) {color: purple;.my-element__body {background: white;}}`;
The easy-to-read nature of Block
, Element
and Modifier
is masked by the :host-context
wrappers that make it harder to differentiate what is a Block
vs a Modifier
, not to mention the awkwardness of maintaining a stable class on the Host
element.
Adding a class to the first element inside is easier, but now what do we do with the styles on the Host
element? Especially when the layout will potentially be affected by its parent layout model (grid, flexbox, etc).
class MyElement extends LitElement {render() {return html`<div class="my-element">[...content...]</div>`;}}
We could ignore the Host
element by adding display: contents
, but that comes with its own set of gotchas and we are still needlessly adding extra markup. It also makes it difficult to select the element when using DevTools for debugging.
Elements and ::parts
Another option for styling web components is to not include any CSS in the web component and instead expose parts of the component for styling using the part
attribute. This can be a pro or a con because now we can apply the CSS to at least the host element of the web component before the JavaScript has kicked in to initialise it, but we have to keep our CSS build somewhere separate from the component as it will not be self-styling.
If the web component applies a Block
class to itself as shown above, styling it from the light DOM is easy, as we can use normal CSS.
.my-element {color: purple;}
The awkward part is that we cannot touch the components’ shadow DOM classes from our light DOM CSS.
.my-element__body {background: blue; /* Will not be applied */}
The only way we can style the inner shadow DOM is if the component adds a part
attribute to the element we want to style.
class MyElement extends LitElement {render() {return html`<div class="my-element"><div class="my-element__body" part="my-element-body">[...content...]</div></div>`;}}
Now we can access the element within, but we’re still blocked from using our standard BEM syntax because we can’t use the class selector.
.my-element {color: purple;::part(my-element-body) {color: blue;}}
Now, this CSS is not traditional BEM, but it actually looks OK. It feels a bit more legible than the weird host context mixed classes mess, but we still have the problem of applying the Block
class with JavaScript and having to maintain that, as well as adding Modifier
classes.
But this does lead us to…
BEM for web components.
If you remember, back at the start, we said that BEM does not mean double underscore and double hyphen, it just means defining a selector format for determining if a CSS selector is for a Block
and Element
or a Modifier
.
Our selectors should:
- Namespace our styles so we don’t get clashes
- Be simple to apply to components and elements within
- Be easy to read and fast to determine which selector type is which
We still have the option of light DOM vs shadow DOM CSS, which leads us to 2 main approaches.
Shadow DOM
:host
.element
[modifier]
In shadow DOM CSS we have a special selector called a :host
selector, which applies to the Host
element of the component. This prevents us needing to add a class to the base element and removes the need to a :host-context
selector to wrap it. The :host
element is always the BEM Block
.
Shadow DOM CSS is encapsulated so class selectors do not leak in or out from the light DOM. This means there is no need to namespace our Element
selectors with the block name. We can use pure, simple, class selectors, such as .body
.
Because component props are passed as attributes, we can use attribute selectors as our Modifiers
. There is no need to convert the attribute to a class with JavaScript for use in CSS because the attribute already exists.
class MyElement extends LitElement {static styles = css`:host {color: green;}.body {background: blue;}:host-context([rounded]) {border-radius: 20px;}`;@property({ reflect: true }) rounded?: boolean;render() {return html`<div class="body">[...content...]</div>`;}}
Light DOM
custom-element
::part
[modifier]
Something about rendering all our HTML and CSS using JavaScript makes my progressive enhancement nerve twitch so we’re always looking for ways to keep JavaScript out and use the least tech possible. Keeping JavaScript out of CSS is always appealing and using light DOM CSS and part selectors lets us move (at least some) styling over to pure CSS.
In this scenario, the Block
selector becomes the custom element selector, such as my-element
, where we select it just like any other HTML element, such as a button
or an h1
.
Elements
become ::part
selectors by adding part
attributes to elements in the shadow DOM.
Just like in the shadow DOM example, we can use attribute selectors as our Modifiers
.
Because CSS nesting is now supported fairly widely, this approach to styling web components gives us quite a familiar layout comparable to traditional BEM styling in SCSS.
my-element {color: purple;::part(body) {background: blue;}&[rounded] {border-radius: 20px;}}
tl;dr - the solution
Don’t get trapped in traditional .block__element--modifier
, optimise your selectors for your tech today.
:host
.element
[modifier]
for shadow DOM
and
custom-element
::part
[modifier]
for light DOM
Are both great approaches to clean, modern, styling of native web components.