Cabinet of Cursed CSS: The Double-Negative Selector

Regrets, dear reader, but I am up to no, no good.

Years ago, when I made my CMS take the jump from WYSIWYG to Markdown, I found myself in a bit of a situation.

I wanted to style lists in blog posts a certain way, but I also liked using single classes for my CSS. Markdown did not let me do that without marking up the list myself every time. Not only am I lazy, I do not memorize class names. I could make all lists on this site have the same styles and let classes override them, but that's extra code and if there's one thing I like more than single-class selectors, it's a clean view of style inheritance in dev tools. I could just scope an ul/ol selector with .blog-post and call it a day, but now we're talking about upending my ideal form of CSS.

In my own house? No, I don't think so.

I took this personally. And when I take things personally, I overthink. And when I overthink, I make complicated solutions of questionable value. And when I make complicated solutions of a questionable value, I share them here.

I had my constraints:

  1. Style lists in a blog post
  2. Do not require a scoping class
  3. Do not create styles that need to be overridden by a class in other contexts

The first question was, how do I select specific elements without scoping or applying classes directly? What do those elements look like—how can I find a list element that doesn't have a style? To my knowledge, CSS does not provide a way to select an element that has not been selected previously, so we need a clue. For me, that clue is the absence of a class or an inline style:

ul:not([class]):not([style]),
ol:not([class]):not([style])

I call it the double-negative selector.

This checks all three requirements: lists in blog posts lack both class and style (with all due respect of course), so this will apply to them, it doesn't need a scoping class to do that, and there is nothing to override because any styles applied with this selector disappear entirely once a class or an inline style are applied.

It's 2023. We have new tools. We can do better.

:where(ol, ul):not(:where([class],[style]))

Using :where() removes the duplication for each type of list, love to keep things simple around here.

If we didn't change the attribute selectors, too, I'd be short-changing only myself.

Extending the use of :where() into the attribute selectors (:not(:where([class], [style]))) achieves the same result as using a list of selectors (:not([class], [style])), but "selectors inside :where() have specificity 0" (MDN), so the entire selector ends up with a specificity of 0.

The specificity isn't much use here because the styles will disappear in the presence of a class or inline style, but if I'm going to make things weird, I'm not leaving anything on the table.

Speaking of...

The cursed CSS

A few months ago, I wrote about using :has() to define columns for a list based on the length of its contents—one column for every twenty items until 60, then it's stuck at three. That works. Kinda. I'd much rather default to laying out a list into a column layout based on the available space.

Container queries rule:

:has(> :where(ol, ul):not(:where([class],[style])) :where(li:nth-child(12))) {
	container-type: inline-size;
}

:where(ol, ul):not(:where([class],[style])) {
	columns: var(--columnsCount, 1);
}

@container (min-width: 400px) {
	:where(ol, ul):not(:where([class],[style])) {
		--columnsCount: 2;
	}
}

@container (min-width: 600px) {
	:where(ol, ul):not(:where([class],[style])) {
		--columnsCount: 3;
	}
}

This uses :has() to select any direct ancestor of an assumed-to-be unstyled list that contains at least 12 li children, making it "a query container for dimensional queries on the inline axis of the container." (MDN. Throwing some arbitrary widths on there, the unstyled lists will have one column for every 200 pixels of its direct ancestor's width.

Worth pointing out that the :nth-child(12) psuedo-class is only used on the direct ancestor selector because if the container is not defined (container-type), the queries fail, and the double-negative defaults to a single column list thanks to the undefined custom property, --columnsCount.

Adding :has() into the equation does not add any specificity. The net-zero specificity comes in handy because you can use any ancestor and create a container to override the value of --columnsCount, and any selector will work without forcing it or creating an overly specific selector.

But where is the curse?

Look at the direct ancestor selector. Look at it:

:has(> :where(ol, ul):not(:where([class],[style])) :where(li:nth-child(12)))

Look at it and tell me anything makes sense anymore.

In a single line, using only type and pseudo-class selectors, without producing a bit of specificity, without needing to add anything but the HTML, I can select the parent of an element which contains at least 12 li children and which, to the best of my knowledge, has no previously applied styles.

This is a selector that should not be written. It is a threat to the church.

I have been mulling this over for a long time, reworking my answer as new modules land in browsers. Been a lot of fun. Now that we have container queries in all browsers, this cursed CSS is ready for production. You can see it in use in the list of constraints at the start of this post.

Unabashedly, this is a prime example of my "I want anyone who sees this to think, wow, he knows stuff" code. Plain overwrought. There is literally an easier solution, one most people can look at and say "Yes, I know what is going on." Instead, the longer you gaze at this selector, the longer it gazes back at you.

In certain circumstances, sure, it could be useful. Like I'm using it here, I think there could be a use for this in handling a CMS' opinionated output. But there are better solutions, something other people can work with and understand: a scoping selector .blog-post :where(ul,ol) would work just fine.

Just do that.

Or don't. I'm not the boss of you.

Use the double-negative selector everywhere.

Never think about the double-negative selector again.

Write a rebuttal.

Write your own cursed CSS.

Write a rebuttal that includes your own cursed CSS.