One Neat CSS Trick

I saw a neat little trick posted on Mastodon by the ever-awesome Stephanie Eckles:

I'm not sure enough folks realize that :is()/:where(), and :not() can check ancestor and previous sibling conditions.

p:is(h2 + *) = paragraphs that are directly after an h2
p:not(blockquote *) = paragraphs that are not within blockquotes

I've used these techniques to simplify what would otherwise be rather unmaintainable selectors or may have previously required additional conditional classes.
 

She's right, I hadn't considered using wildcard selectors as a kind of self-referential variable before, and that feels very powerful! In fact, my brain read this and immediately went "wait, ancestor condition‽ Does that mean we have another way of creating parent selectors‽‽" 🤔 Because whilst we do have one of those (at long last!) in CSS via the :has property, it isn't quite at the level that I feel comfortable using it just yet, so an alternate path would be useful.

Unfortunately, having actually played around with this technique a little today, that isn't really what's happening here; nor is it what Stephanie even said 😅 Instead, for :is and :where, I'd describe this more as a way of inverting the normal selector hierarchy. For instance, the example given in the original post can be written as follows:

h2 + p { ... }

It's cool that you can write this the other way around, but it's more of a novelty that will be useful in certain, specific scenarios. There are definitely some readability pros, for instance, but I can see some cons too. For starters, it wasn't immediately obvious how this was working the first time I read the code snippet, so whilst it's an easy pattern to learn, it could trip up other developers that haven't seen it before. On the other hand, there are definitely some places where this will improve readability by placing emphasis within the selector in a more meaningful way – by listing the negations after the target.

The :not example is a bit more interesting, though, because you can't rewrite it in the same way. If you were to try this:

*:not(blockquote) p {
    border: 1px solid rebeccapurple;
}

You might expect paragraph elements within blockquotes to not have the border assigned, but they still will. There are a few reasons as to why that's the case, but the short answer is that there are multiple possible routes from blockquote to paragraph that could occur, and they'd all be exceptions to that rule (e.g. <blockquote><div><p>...</p></div></blockquote>). As a result, the rule can't be applied[1]. Instead, the closest you can get without Stephanie's trick is to enforce the direct-child relationship:

*:not(blockquote) > p {
    ...
}

Now, any paragraphs directly nested within a blockquote will no longer get a border. Of course, as shown above, that isn't necessarily what you want, and the inverted order therefore gives you a much more powerful selector. I actually ran into this issue on a production bug recently and I wish I'd had this trick up my sleeve!

That alone is a pretty great reason to record this technique as a useful solution, but I think there's somewhere else – very specific to React and similar front-end frameworks – where this can be very powerful, and that's in letting components look up their page-level context from within the component code, without giving up tight style encapsulation. Let me explain.

Attribute-Driven State Stores & Leaky Styles

There's not much novel to suggesting the use of HTML attributes as state storage. At work, we've been creating attributes like data-theme and data-user-validation for years, and we were far from the first to think of doing so, talk about it, etc. Heck, libraries like Bootstrap were doing this with class names long before arbitrary attributes became a common technique.

But when you're working in a component architecture, it's not always the simplest idea to implement. Let's say you have a site with a handful of page templates. On one template, you want a specific component to have a full-width layout. But on another template – perhaps a blog article – you might want to restrict that width.

You could add some kind of prop to the component that states whether or not it should enforce a full-width layout. On some templates, this prop is set to "enabled", on others it's "disabled". It's not a hard pattern, but it gets a bit more complex once you start adding transformer layers, rich text components, and other "levels" within your rendering pipeline. You can prop-drill your way through those layers, but that adds a lot of redundant code. Or, you might use some kind of global data store or state management system but – ooph! – that's a lot of additional complexity and maintenance overhead just for a small UI tweak.

The technique I'm most-used to using at this point is hierarchical CSS. So you'd define the base styles in the component, and then provide page-specific overrides at the template level. This is very easy in HTML-native frameworks, where you can set a class on the component and target that from the template:

.article .my-component {
  width: 50%;
}

But once you get into abstractions like Styled Components, you'll find that those "generic" class names are a complete pain in the butt to get working. It's possible, but it goes against the grain of how those systems are meant to work, and so won't benefit from lots of their build automations, like tree shaking and critical style assessment. It effectively steps certain rules out of your build process – not ideal! On top of which, your component's styles are now scattered to the four winds, spread across templates and other components, with no easy way of tracking down all of the distributed parts. Wait, wasn't avoiding tightly-coupled UI most of the point of switching to component-led architecture in the first place? Whoops!

Which is why we end up using data attributes so much. These can be set at the template level, and then accessed at the component level. But – once again – this has an issue: it makes your styles leaky. (Sort of.) The caveat here is (once again): it depends on your specific set up, framework choice, etc. But in many combinations, if you want to access an ancestor of the component, you have to break that style rule out of the component's inherent encapsulation. Something like this:

:global([data-template="article"] .my-component) {
    width: 50%;
}

You can often use placeholder tokens for your component specifically, to gain at least some level of encapsulation back, but that introduces a whole new set of headaches around competing syntaxes and methodologies. It works, but it's not a great developer experience.

A Better Path

And this is where Steph's trick comes in particularly handy. Remember I said that this can be thought of as inverting the selector hierarchy? Well, that's exactly what we need here. If we can write styles based on the current component selection, and then work backwards from there (rather than forwards from some higher-level ancestor), we get to keep the easy encapsulation and co-location that component architectures intended.

So instead of breaking rules out of style blocks with global overrides, you can do something like this:

.my-component:where([data-template="article"] *) {
    width: 50%;
}

Or make it even nicer using nesting and the "ampersand" trick – which references the current top-level selector – and have your overrides placed right alongside the rest of the code, all nicely nested together:

.my-component {
    width: 100%;

    &:where([data-template="article"] *) {
        width: 50%;
    }
}

From my quick experimentation, this allows us to:

  • Colocate all component-related styles in a single place (either a sidecar file or within the component template itself), improving maintainability;
  • Easily pass state and context around from higher-level components to any children, regardless of the number of layers in between the two, reducing complexity;
  • Still gain all of the benefits from modern style abstractions, such as universal nesting, build automations, and unique class names, without using proprietary keywords or functions.

And, personally, I think it's a pretty easy pattern to learn, read, and use.

In fact, less than a day after reading Steph's post, I've already been able to use this technique twice to solve bugs with one or two lines of code each; bugs that would have normally required a bunch of prop-drilling or complex testing to ensure styles weren't leaking. That's a real win-win! I imagine I'll be using it a lot more moving forward 🎉

Explore Other Articles

Conversation

Want to take part?

Comments are powered by Webmentions; if you know what that means, do your thing 👍

Footnotes

  • <p>Using pseudo-selectors like :where and :not to invert style rules, allowing for better code encapsulation and context sharing across a codebase.</p>
  • Murray Champernowne.
Article permalink