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.