It feels like the “cascading” part of CSS is for “cascading ripple effect”

Why the Hardest Part of CSS is Specificity

Contemplating a tool that helps find those nasty but inevitable cascading ripple effects

by Joe Honton

In this episode Clarissa shares her frustration over her inability to engineer maintainable CSS.

It was Tech Tuesday at Tangled Web Services and no one had anything exciting to share. Devin and Ken's performance benchmarking had been put to bed. Ernesto's excitement over speculative push had died down. And bug triage and JIRA grooming was looming. Everyone was in a grumpy mood.

And it was still only Tuesday.

Even Clarissa, who was always the most sanguine of the crew, was noticeably on edge. But since no one else was volunteering to share their tech news, Clarissa decided to fill the void.

"I've come to the conclusion that the hardest part of CSS is specificity," she began. "Not naming conventions, not modularity, but specificity."

Devin and Ken, die-hard backend engineers, wanted to roll their eyes. What's so hard about CSS they were thinking. . . . Those front-end guys don't know what real problems look like. . . . CSS is for people who can't write algorithms. (Of course we can't know for sure what they were thinking, but we can guess that it was something along these lines.)

"By specificity," Clarissa explained, "I mean the algorithm that the browser follows to determine the hierarchical cascade of selectors that apply to each element of the page."

The guys started fidgeting in silent shame at their unkind thoughts, as she matter-of-factly laid out the problem in engineering jargon, but with impressive clarity.

Clarissa leaned into it. Her explanation went something like this —

CSS allows three types of things to be used in rules: tag types, classnames, and identifiers. So each element of the page can be the target of multiple selectors at the same time. On top of this, CSS also allows individual elements to declare their own rules — with the style attribute — to override the selector-based rules.

CSS rules may be simple, such as declaring the paragraph tag type to have a default padding. Or they may be complex, such as declaring that all paragraphs, except the first one after a heading, should be indented.

But this is misleading, because specificity is not about simplicity versus complexity. Rather, it's about degree of importance. And importance is determined mathematically by counts and weights. That is, how may names are used to declare the selector, and what is the weight of each name.

Names are weighted like this:

selector     |  weight  |  examples
------------ | -------- | -----------------------------
tag type | 1 | p span div
classname | 10 | .intro .first .primary
pseudo-class | 10 | :first-of-type :last-child
identifier | 100 | #err-msg #textblock #figure1
style | 1000 | <p style="text-indent:1em">

The formula for specificity (Sp) is straightforward:

Sp = Tn + (Cn * 10) + (Pn * 10) + (In * 100) + (S * 1000)

where Tn is the number of tag types in the selector, Cn is the number of classnames in the selector, Pn is the number of pseudo-classes in the selector, In is the number of identifiers in the selector, and S is 1 when an element has a style attribute and 0 when it doesn't.

For example, consider these four HTML elements:

<p>Only Australia and Antarctica are true continents.

<p class="suez">Asia and Africa are joined by the Isthmus of Suez.

<p id="panama">North and South America are joined at Panama.

<p style="text-indent:0em">Europe's claim is simply ludicrous.

They can each be targeted with simple selectors, but their specificities are widely different.

p       { text-indent:0em } /* Sp = 1 */
.suez { text-indent:0em } /* Sp = 10 */
#panama { text-indent:0em } /* Sp = 100 */

The fourth paragraph is styled directly on the element, so it has an Sp of 1000. This is the sledge hammer approach to styling, and it always wins, because no matter how many classnames or identifiers are part of the selector, they will (practically) never add up to be more than 1000.

And the exception to all these is the !important keyword, which can be added to an attribute's declaration, where it will effectively boost that part of the selector by 10,000 — telling the browser to ignore everything else (even the element's style attribute) and just do it!

Now to explain the situation with more complexity, suppose the goal is to force the first paragraph to be in classic book style, without an indent, but to indent all of the following paragraphs.

p + p { text-indent:1em } /* Sp = 2 */

This new selector will correctly target only paragraphs that come immediately after a preceding paragraph, thus fulfilling our goal.

But unfortunately, for our continent example above, this new selector will never be applied because its specificity (2) is lower than the specificity of the selectors on paragraphs 2, 3 and 4 (Sp = 10, Sp = 100 and Sp = 1000).

Solutions to the problem abound. Adding a noindent classname or identifier to the first paragraph and selecting it that way —

p           { text-indent:1em } /* Sp = 1 */
p.noindent { text-indent:0em } /* Sp = 11 */

Or wrapping the block of paragraphs with a new element and targeting the wrapper —

p                        { text-indent:1em } /* Sp = 1 */
.wrapper p:first-of-type { text-indent:0em } /* Sp = 21 */

Or removing the text-indent attribute from .suez and #panama and simplifying the rules to be just —

p     { text-indent:0em } /* Sp = 1 */
p + p { text-indent:1em } /* Sp = 2 */

This last one just feels right. The selectors are generic enough to apply everywhere. And there aren't any classnames to be sprinkled into the HTML, so there's nothing new to proofread. Plus there's no extra non-semantic wrappers bloating the document. It's just styling. True separation of concerns.

But unfortunately there were other places on the page where this new rule wasn't being applied. For example, sections were used to divide the page into manageable pieces, and these did not get the benefit of the new p + p text indent rule:

section p       { font-weight:300; text-indent:0em } /* Sp = 2 */
section.intro p { font-weight:400; text-indent:0em } /* Sp = 12 */
section#quote p { font-weight:600; text-indent:0em } /* Sp = 102 */

And worse, there were places on the page where this new rule was being applied when it shouldn't be. There were paragraphs within asides that were explicitly designed without indentation, but the new rule was applying indents anyway —

<p>Antarctica is a true continent, even though we can't see it.
<p>The Arctic is not — it's nothing but frozen water up there.
aside { font-size:0.8em; text-indent:0em } /* Sp = 1 */

These types of problems could have been avoided if the CSS rules were designed and applied before the content was solidified. But new content challenges and new styling needs come hand-in-hand as the story develops. CSS is always iterative.

Yes, for the most part, adding new rules can be done without too much disruption. But only if you thoroughly proof-edit everything afterwards. And there's the rub.

Unlike other coding practices where you can create test suites, and automate the regression testing process, CSS testing cannot be automated to find all those unwanted side effects. How do you test for a 1px shift that causes text to wrap inside a button? How do you test for two adjacent elements collapsing their top and bottom margins in unanticipated ways? And how do you test for unwanted underscores when some anchors have text-decorations and others have border-bottoms?

On top of this, CSS changes can be global. For example, changes on tag types are, by definition, not local. Like ripples in the pond, such changes can disturb far away islands in their wake. Other parts of your document. And other documents.

And even worse than adding something new to your stylesheet, is trying to make changes. As soon as you try to refactor an existing stylesheet you end up belly flopping in the water. Splash! Ripples!

Trying to get rid of cruft that's built up is a thankless chore. Trying to clean up code to make it orthogonal and tight, is just asking for trouble.

"I've come to the conclusion," Clarissa wrapped up her rant, "that the cascading part of CSS, means cascading ripple effect — every time you change something, you disturb something else."

The room fell silent for a moment.

"I feel your pain," Devin offered.

"But wait," asked Ken, "can't you use the browser inspector to help in some way?"

"Yes, but only to a certain extent," Clarissa answered, without enthusiasm. "The browser inspector lets you poke at a single element and see all the rules and overrides that are assembled into attribute values. But it doesn't have any capability to look at a style sheet as a whole, and see what elements are affected by each declaration.

"I guess what I need is a tool to allow me be pick a target selector and to see all of the related selectors, ordered by specificity. Which ones have a lower specificity and are going to be clobbered. And which ones have a higher specificity and are going to block my target.

"So if any of you are looking for a cool project for your '20% time', this would be a good one."

There were no immediate takers on Clarissa's idea for a specificity visualizer. Ken was deep into a Kubernetes configuration. Devin was rewriting his daily DevOps log monitor, again.

Clarissa didn't really expect her guys to pick up on the idea, but she was glad to have planted the seed. Maybe it would germinate somewhere else.

No minifig characters were harmed in the production of this Tangled Web Services episode.

Follow the adventures of Antoní, Bjørne, Clarissa, Devin, Ernesto, Ivana, Ken and the gang as Tangled Web Services boldly goes where tech has gone before.

Why the Hardest Part of CSS is Specificity — Contemplating a tool that helps find those nasty but inevitable cascading ripple effects

🔗 🔎