I recently wrote a post on how I've combined CSS Modules and Sass together to create a very modern – yet standards-based – approach for styling in React projects, especially those making use of React Server Components (which cannot use CSS-in-JS libraries). Overall, I've been very happy with the way this stack is performing, and now a few of my colleagues have tried it out, I think it's generally a safe and pretty solid approach. But we have been tripped up from time to time. Remarkably, none of these minor snags are issues with the tools themselves; they've all been user error. But I think that highlights that there are some shifts needed in terms of how you think about styling within a component architecture, regardless of the tech stack you're migrating from. The most recent of these was a bit of a head-scratcher at first, but once we'd worked it out, a bunch of new mental models clicked into place, so I thought I'd write it up.
Here's the issue, simplified down into the smallest form factor I could think of. We have a <Button> component with a two-part label. Part one is a fully dynamic piece of text controlled via a prop API; part two is an optional extra flag that we want to show in a different colour. To control this, we have some state and an ID attribute:
export const Button = ({ label, disclaimer }) => { return ( <button type="button" className={styles.button} > { label } { disclaimer && <span>{ disclaimer }</span> } ) }
The SCSS we are using here is very simple:
.button { color: white; span { color: rebeccapurple; } }
So we have a parent element with white text for the regular label, and then apply some purple text within a <span>
element if it is provided.
As is, this works perfectly well and all feels quite neat; our CSS is almost entirely vanilla and the JSX logic is easy to understand. But let's say we wanted to do something a little fancier, such as allowing the use of additional HTML elements within the label
prop. Maybe we sometimes want to throw in some bold text or additional <span>
elements to style other parts of the label, for instance. Well, now we have a problem, because that nested .button
span selector is going to apply our purple styling to all of the matching elements, not just the disclaimer text 🤔
The answer here is to make our disclaimer element more unique, and the simplest solution is to use an ID attribute, like so:
export const Button = ({ label, disclaimer }) => { return ( <button type="button" className={styles.button} > { label } { disclaimer && <span id="disclaimer">{ disclaimer }</span> } ) }
We can then update our selector to use the new attribute:
.button { color: white; #disclaimer { color: rebeccapurple; } }
But if you try this out, you'll notice that it doesn't work. The disclaimer text now stays white; the purple is never applied. What gives?
I imagine anyone who has worked with CSS Modules for some time is currently rolling their eyes at how obvious the answer is, but this genuinely stumped me for a good while. As I understood it, Sass should respect the native CSS selectors and their hierarchy, whilst React should output the native HTML attribute unadulterated. When I looked in the DOM, the ID was present, but the rule just didn't seem to apply. We tried breaking out the nesting, playing around with attributes, using class names, all without any success. The one thing that did work was using a data attribute, which I don't mind, but it wasn't a satisfying solution. Something was clearly wrong here, and I wanted to know what it was.
Searching for the expected selector in the generated stylesheet – .button #disclaimer
– finally gave us our first clue[1]: the ID had been mutated to have a UUID. Okay, that's to be expected; that's the whole point of CSS Modules, it handles scoping for us. I'm not sure I'd expected that to apply to IDs – which are unique by their very definition – but it definitely makes sense for classes. However, shouldn't Sass/CSS Modules know how to rectify that mutation? The .button
class is similarly modified to have a UUID, but that is working fine!
Have you spotted the issue yet? It took me quite a bit of time (and searching) to work it out 😅 Here's a clue: what is the difference between the .button
class and the #disclaimer
ID when you look at the JSX?
...
Yep, one of them is being applied using the imported styles
Object, whilst the other is just a string 🤦♂️ And there's the mental model shift. With vanilla HTML and CSS, or even CSS-in-JS, there may be some scoping occurring, but native attributes are just understood and can be written directly in the JSX. That isn't the case with CSS Modules.
The way CSS Modules works is to create a scoped JavaScript Object that contains all of the attribute names within it. You still have to apply them to the relevant DOM nodes yourself. Once you've realised that, working in this way becomes much easier to debug and generally understand, and the fix for the above is clear:
export const Button = ({ label, disclaimer }) => { return ( <button type="button" className={styles.button} > { label } { disclaimer && <span id={styles.disclaimer}>{ disclaimer }</span> } ) }
This feels like it should have one small caveat: you cannot have a class and an ID with the same name. But actually, it works just fine. The class and ID will have the same UUID (I guess the string gets converted to one node in the object) but otherwise, the actual rules output to the stylesheet will still be unique, because of the other selectors being used. So there doesn't seem to be any way to fool this or trip over it; it "just works". Neat 👏
Okay, but what if you need to target a pre-existing ID on, say, a third-party component? Somewhere you can't pass the CSS Modules Object to or reference it from? Are you cooked? Nope! In trying to debug this issue, I stumbled onto a slightly roundabout technique that nevertheless works fine: CSS Modules ignores any kind of selector in square brackets. This is why data attributes work so easily, but CSS lets you target any HTML attribute this way, including both ID and class. No one ever does it this way because of the shorthand .
and #
selectors, but it's technically in there under the hood.
In this case, your SCSS would look something like this:
.button { color: white; [id="disclaimer"] { color: rebeccapurple; } }
And yes, this does work even with internal components using CSS Modules, if you absolutely need to control an attribute without referencing the styles
Object directly (for some arcane reason) 😉
I'm sure there will be some other small gotchas and mental shifts that need to be overcome as we continue trialling this old-is-new setup, but honestly, it continues to just feel like a really solid way of managing CSS at a component level. And even with these irritating little niggles, the outcome has regularly felt like an "aha – that makes sense!" moment than the "oh god, but why‽" that I'm used to with other, more "modern" approaches (*cough* Tailwind/React *cough*). It seems stupid to say that it just works, but, well, that does seem to the case. I guess future blog posts may yet appear to refute that, but fingers crossed this was the biggest issue we run into 🤞