Hajime, the duck guy

Friday, October 11, 2024, by Hajime Yamasaki Vukelic

Breaking the CSS module encapsulation

CSS modules are used to encapsulate styling. Technically they don't encapsulate styles but just the class names, but it's close enough for all intents and purposes.

The problem with encapsulation is sometimes we don't want it.

In this post, I'm going to show you ways to work around class name encapsulation. I'll be using React in the examples, but this works more or less the same way in any framework that allows you to use CSS modules.

The example

Here's a completely made-up but realistic scenario. We have components A, B and C. The component A can be used inside either B or C, but the base text size is 110% inside component B and 87.5% inside component C.

We have two kinds of styles. We have local styles (encapsulated inside the modules) and we have contextual styles, which are applied to one of the descendants but defined by the context (that is, one of the ancestors). In the example, the text size of the component A is contextual, and defined in the components B and C.

The usual CSS way

In plain CSS, we'd normally do something like this:

.b .a {
    font-size: 110%;
}

.c .a {
    font-size: 87.5%;
}

and that would be the end of it. If things were this simple, I wouldn't be writing this article, now would I? 🤣

Why it doesn't work

Well, 🦆... we're using CSS modules here, so the simple solution won't work. This is because regardless of what module we use .a in, it's going to be rendered as a module-specific mangled class name like .amodule__a__randomHash1083af0d. This class name is unusable in other modules as they have no way of knowing what the final class name will look like.

So what do we do? In the following sections, I'm going to show you a few different ways of dealing with this, ordered from the worst possible (imo) to the best.

Solution 1: Passing a class name to a child

Let's suppose we declare the font size in B's CSS module:

/* B.module.css */
.b .a {
    font-size: 110%;
}

We said this .a class is not the same as .a class in A.module.css so I need to let A know about it.

The component B looks like this:

import css from './B.module.css'
import A from './A.js'

export default function B() {
    return (
        <div className={css.b}>
            <A className={css.a}/>
        </div>
    )
}

And component A looks like this:

import css from './A.module.css'

export default function A(props) {
    return <div className={`${css.a} ${props.className}`}></div>
}

I call this the anti-CSS solution. (Though, honestly, the whole idea of CSS modules is anti-CSS to begin with.) It's anti-CSS because it fails to take advantage of the cascade, selectors, or any other useful CSS feature that will normally solve this problem. It's basically working too hard. I'm lazy so I don't like this type of solution. 😴

Solution 2: use a 'global' class name

A global class name is another name for normal class name. In CSS modules, we need to special-case it cause it only uses abnormal class names.

/* B.module.css */
.b :global(.a) {
    font-size: 110%;
}

To apply this global class in component A, we do this:

// A.js
import css from './A.module.css'

export default function A() {
    return <div className={`${css.a} a`}></div>
}

You will notice that, in addition to .a coming from A.module.css, I've added a plain .a class as a plain string. When the code is compiled, these will become two distinct classes, one looking like .amodule__a__a97e109f710 and another being just .a.

For all intents and purposes, this is the 'simple' solution, but with the CSS modules twist, extra syntax, and some unnecessary duplication.

Solution 3: using dats attributes

Using data attributes is similar to the previous solution except that it doesn't use any non-standard CSS. First I mark the element in the A component using a data-part attribute (you can use any name for the data attribute).

import css from './A.module.css'

export default function A() {
    return <div className={css.a} data-part="A.root"></div>
}

Then in B component's CSS module, I select by attribute:

/* B.module.css */

.b [data-part="A.root"] {
    font-size: 110%;
}

I picked the A.root name with the intent of allowing possibly multiple elements to be selected (e.g., A.header, A.button). In other words, I'm just replacing class names with data attributes. That's pretty much it.

If you squint hard enough, this is similar to how web components expose parts of their shadow DOM to the host document's CSS using the part attribute, and how we access those parts using the ::part() pseudo-element. (Not a big fan of that either.)

Solution 4: using custom CSS properties

In this case, we are going to define a custom property (also known as a CSS variable) in the A component's CSS module, and allow any ancestor component to override it. This solution doesn't require any changes to the JSX.

/* A.module.css */
.a {
    font-size: var(--a-font-size, 100%);
}

The second parameter in the var() function is the fallback value in case the --a-font-size property is not defined. Now in the B component's CSS module we use this custom property like so:

/* B.module.css */
.b {
    --a-font-size: 110%;
}

Note that we don't need to select the A component's elements at all because descendants inherit any custom properties declared upstream (except when such properties are explicitly declared as non-inheritable using the @property rule).

This method has a few advantages over the previous methods. First it does not require any modification to the JSX (no attributes, no extra imports, etc). Secondly, and more crucially, it does not completely break the encapsulation. Although the component A has to leak a little bit of information (the variable), it does not leak any details of the actual styling. The component B only declares that --a-font-size is 110% but has otherwise no control over how A will interpret this (e.g., A could conditionally ignore it, or provide a minimum font size).

In my opinion, this is a more powerful method of providing style customization than the other methods, precisely because it gives the least amount of power to the descendants, while successfully achieving the goal of allowing contextual styling.

Incidentally, this method works equally well even when there is no encapsulation via CSS modules (or other forms of scoped CSS). It can also be applied to any kind of stylesheet technology that understands custom properties, and with custom elements that use shadow DOM.

Solution 5: Don't use CSS modules

D'uh. Kinda obvious but has to be mentioned.

Posted in Programming tips
Back to top