D
P
0
← All articles Baca dalam Bahasa Indonesia

Next.js & React in Production

CSS Modules Rejects a Data-Attribute Selector: `selector is not pure`

· · 5 min read
CSS Modules Rejects a Data-Attribute Selector: `selector is not pure`

I wanted every article on this blog to carry its own accent color. Not a theme readers can switch, just one number in frontmatter deciding the mood of a single page: the small kicker above the title, the short rule under it, the link hover color, and the related-article cards at the bottom. Ten values, one color per number.

The plan was meant to be as short as possible. The number from frontmatter goes onto a wrapper element as a data-mood attribute, the CSS defines one custom property per attribute value, and every descendant simply reads that property. Only three things are involved: Next.js 16 App Router, CSS Modules, and ordinary CSS custom properties.

I wrote the first block like this, inside the blog section's CSS Module:

/* articles.module.css */
[data-mood="7"] {
  --tint: #8a6a3b;
}

Compilation stopped right there. CSS Modules refused the selector, and the error carried the phrase not pure. The color is only an example, and its value has nothing to do with the rejection. What got rejected was the shape of the selector.

Why a bare attribute selector counts as impure

CSS Modules requires every selector to contain at least one local class or id. An attribute selector standing on its own contains neither, so it reads as a global selector, and the purity check rejects it as impure.

Once you recall what CSS Modules is actually for, the rule makes sense. Its whole job is scoping class names so they cannot leak outside their file. A selector with no local hook has nothing to scope, so it would quietly become a global rule applying to the entire document. Rather than let that slip through silently, the compiler stops.

The part that briefly fooled me: the selector is perfectly valid as plain CSS. There is nothing wrong with [data-mood="7"] in any global stylesheet, and no browser would complain. What rejected it was not the CSS parser but the layer of rules CSS Modules puts on top of CSS.

The fix: wrap it in :global()

/* one block like this per value, 1 through 10 */
:global([data-mood="7"]) {
  --tint: #8a6a3b;
}

:global() declares that the selector inside it is meant to be global and does not need scoping, so the purity check has no reason left to refuse it. All ten accent colors are defined in this shape, and this shape is the only one that compiles. The bare version will always fail.

One custom property, four wrappers

The attribute sits on wrapper elements, and its value comes from frontmatter. Four classes carry it, to be exact: the article wrapper, the featured card, the item in the listing grid, and the related-article card.

<article className={styles.shell} data-mood={meta.mood}>

After that, nothing else needs to know about the number. Custom properties inherit to descendants, so once --tint is defined on the wrapper, everything inside can read it. Four things get tinted this way: the kicker, the title rule, the link hover, and the related-article cards.

.kicker { color: var(--tint); }
.titleRule { background: var(--tint); }
.prose a:hover { color: var(--tint); }
.linkedCard:hover { border-color: var(--tint); }

Notice that these consuming rules never had trouble with the purity check. Every one of them leans on a local class, so they satisfy the requirement without any special handling. The only problematic block was the one defining the property, because it was the only selector with no class in it at all.

The related-article cards needed the color in the first place because, in the same batch, the related list changed from a text list into thumbnail cards with an accented hover.

Rolling it across the whole archive

The number field is parsed in the metadata module, on the post type the blog uses everywhere. The distribution rule is plain: every three articles share one value. With ten values available, one full rotation covers 10 x 3 = 30 articles.

The batch that brought the theming in added 30 new articles, so that rotation lands exactly on the new articles alone. The problem is that if only the new ones got the field, the 22 older articles would stay colorless and the blog would look patchy. So the field was filled into those 22 older articles too, by their date triplets, which makes 22 + 30 = 52 articles themed across the board rather than only the new ones.

The batch finished at 52 Indonesian and 52 English articles, meaning 52 + 52 = 104 MDX files, a build exiting 0, and 122 rendered pages.

What I took away