Native CSS nesting is coming to browsers soon. With nesting, that you might be familiar with from Sass or Less, you can greatly cut down on writing repetitive selectors. But you can also really work yourself into a corner if you’re not careful. This is an overview of how you can already use it today, […]
Comparing CSS Specificity values
CSS Specificity is usually written out as [a,b,c]
for ID’s, classes and elements respectively. Even a single ID is more specific than any number of classes or elements, so it’s displayed as an array and can’t really be fitted into a single number. How do you compare two selectors to decide which has the highest specificity?
CSS Specificity
If you haven’t calculated CSS Specificity before, here’s a quick example:
The selector header h1#sitetitle > .logo
has:
- 1 ID (
#sitetitle
) - 1 class (
.logo
) - 2 elements (
header
andh1
) - A child selector (
>
) which has no specificity at all.
So the resulting specificity is [1,1,2]
.
When you start using more complex CSS selector, using pseudo-classes like :not()
and:is()
, things get a little more complicated.
If you want to test our your own selectors, or play around with selectors to get a better feel for CSS specificity, I wrote an online calculator you can find here: CSS Specificity calculator. I’ve prefilled the selector above so you can see which parts go in which ‘bucket’.
Comparing arrays
If you have a list of selectors with specificity arrays that you want to sort, you could go compare their a
s first, then their b
s and then their c
s, and bailing when the first difference is found. Because a difference in IDs will never be overwritten by classes, and a difference in classes will never be overwritten by elements, you can return as soon as you find a difference at that level.
The sorting function for that is a little bit complex:
function compareSpecificity(a, b) {
if(a[0] !== b[0]) {
return b[0] - a[0];
}
if(a[1] !== b[1]) {
return b[1] - a[1];
}
if(a[2] !== b[2]) {
return b[2] - a[2];
}
return 0;
}
This will return a positive or negative number (or 0
) depending on whether a
or b
is higher in specificity. It works, but it’s verbose. Maybe we can combine the numbers in some way to simplify our code.
The goal is to end up with a single number for each specificity that we can then compare and sort with.
The naive approach
A naive approach would be to add all three parts in a string and then parsing it to an int:
const specificityNumber = parseInt([a,b,c].join(''), 10);
While this will work in many situations, it will only work when the number of digits for a, b, and c are the same. When there’s a different number of digits, the comparison breaks. For example both [2, 0, 2]
and [0, 20, 2]
, while being joined to "202"
and "0202"
respectively, will end up being 202
when cast to an int, leading to an incorrect comparison.
This approach has two issues:
- Leading zeroes get removed.
- Increasing an order of magnitude can “leak” into the higher specificity.
We need to solve both issues if we want to end up with numbers we can compare.
Preserving leading zeroes
Leading zeroes get removed because they don’t mean anything: 00010
of something and 10
of something are the same amount. So we need to make those leading zeroes significant.
The easiest way to do that is to add a digit in front of the string: 500010
is quite a different number from 510
, suddenly those leading zeroes are significant.
Which digit you add doesn’t really matter, as long as you add the same one. In my function, I’m going to stick with 1
:
const specificityNumber = parseInt(`1${[a,b,c].join('')}`, 10);
I’m using a template literal string (using backticks) to add a number before the specificity array. This way it won’t look like we’re trying to do calculations with strings like when we’d use "1" + [a,b,c].join('')
.
Take orders of magnitude into account
While we’ve solved the issue of leading zeroes, having different orders of magnitude can still cause issues:
[0,2,20]
and [0,22,0]
both end up being 10220
(with the prefixed 1
). The second array should win because it has a higher specificity: it has 22 classes while the first just has two.
To prevent this from happening, we can make each item in the array the same length. To do that, we first need to find out what the largest number of digits in the array is:
const largestNumberOfDigits = `${Math.max(...specificityArray)}`.length;
With this bit of code we spread our array into the Math.max function, which returns the largest number in our array. Because it’s in a template literal again we can get the length of the string. With [0,2,20]
, the largest number is 20, and the number of characters in the string “20” is 2. So the largest number of digits is 2.
Next, we need to pad each number so it has the same amount of digits. We can do that by mapping over the specificity array, converting each number to a string and using the “padStart” function to add zeroes to the beginning:
const paddedArray = specificityArray
.map(x => `${x}`.padStart(largestNumberOfDigits, '0')
padStart
adds '0'
to the beginning of the string until the string has the length of largestNumberOfDigits
.
If we start with [0,2,20]
, the padded array will be ["00","02","20"]
(notice the conversion to strings) and [0,22,0]
becomes ["00","22","00"]
.
If we join those arrays and add the leading zero again, we end up with 10220
and 1002200
and we can see that the second specificity array wins out.
Adding it together
When we combine the three parts together, this is what you end up with:
const largestNumberOfDigits = `${Math.max(...specificityArray)}`.length;
const paddedArray = specificityArray
.map(x => `${x}`.padStart(largestNumberOfDigits, '0')
const specificityNumber = parseInt(`1${paddedArray.join('')}`, 10);
combining it even further, you end up with a two-liner function:
function convertArrayToNumber(specificityArray) {
const largestNumberOfDigits = `${Math.max(...specificityArray)}`.length;
return parseInt(`1${specificityArray.map(x => `${x}`.padStart(highestNumberOfDigits, '0')).join('')}`, 10);
}
And here’s how to use it in a sorting function:
function sortBySpecificity(selectorA, selectorB) {
return convertArrayToNumber(selectorB) - convertArrayToNumber(selectorA);
}
Is this more readable than the array comparison at the top of the article? You can argue both ways, if you ask me.
However, by splitting it up we can more clearly show the intent, which is not as easy by comparing different parts of an array. The function to calculate the specificity number is now separate from the comparison function, allowing for re-use elsewhere in your code.
Can this be done easier? Should I add comparisons to Polypane’s CSS Specificity Calculator? Let me know!
Suggestions #
Florian Reuschel shared a few options on Twitter. What makes his code different is that it works recursively. It first compares the ID values and if they differ, immediately returns their difference. If they’re the same value, the code moves to comparing classes and then elements. It’s a clever solution even if, as Florian mentions, a little hard to read.
The initial recursive version:
const compareSpecificity = (a, b) => a.length
? (b[0] - a[0] || compareSpecificity(a.slice(1), b.slice(1)))
: 0;
The even shorter version Florian came up with later:
const compareSpecificity = (a, b) => a.length && b[0] - a[0]
|| compareSpecificity(a.slice(1), b.slice(1))
Finally, here’s the same code written out in a more readable function so you can see what’s going on.
function compareSpecificity(a, b) {
// Nothing to compare?
if (a.length === 0) return 0
// First items equal? Go next
if (a[0] === b[0]) return compareSpecificity(a.slice(1), b.slice(1)))
// Resort to subtraction
return b[0] - a[0]
}
Thanks for this addition Florian!