CSS セレクタの詳細度 (specificity) をトークンごとに分解して計算し、複数セレクタを並べて「どれが勝つか」を判定するツールを作った。詳細度は
(a, b, c)の 3 つ組で、一見「ID・class・要素を数えるだけ」に見える。だが実装の hinge は:is()/:not()/:has()/:where()の扱いにある。ここを:nameをそのまま数える素朴な実装にすると、勝敗予測が普通に外れる。完全ブラウザ完結。
🌐 デモ: https://sen.ltd/portfolio/css-specificity-calculator/
📦 GitHub: https://github.com/sen-ltd/css-specificity-calculator
詳細度とは
ある宣言がどのスタイルに勝つかは、まず詳細度で決まる(同点なら最後にソース順)。詳細度は 3 つの数 (a, b, c) で表し、左から順に大小比較する。上位の桁が 1 でも大きければ、下位の桁がいくら大きくても勝つ。
| 桁 | 数えるもの | 例 |
|---|---|---|
| a | ID セレクタ | #header |
| b | class・属性・擬似クラス |
.btn [type=x] :hover
|
| c | 要素型・擬似要素 |
div ::before
|
// (a,b,c) を左から比較。-1/0/1 を返す
export function compare(x, y) {
for (let i = 0; i < 3; i++) {
if (x[i] !== y[i]) return x[i] < y[i] ? -1 : 1;
}
return 0;
}
1,0,0 は 0,99,99 に勝つ。ID 一つは class 99 個より強い。これが「桁を跨いで繰り上がらない」詳細度の本質で、テストで固定しておく:
test("compare is lexicographic", () => {
assert.equal(compare([0,1,0], [0,0,9]), 1); // class 1 個 > 要素 9 個
assert.equal(compare([1,0,0], [0,9,9]), 1); // ID 1 個 > その下全部
});
全称セレクタ * と結合子(> + ~ と子孫を表す空白)は詳細度ゼロ。これも忘れがち。
hinge: 関数擬似クラスの重み
ここが本題。:is() :not() :has() の重みは :name 自身ではなく、引数リストの中で最も詳細度の高いセレクタになる。そして :where() は常に (0,0,0) で、引数は完全に無料。
const MATCHES_ANY = new Set(["is", "not", "has"]); // 重み = 引数の最大
const ZERO_PSEUDO = new Set(["where"]); // 重み = 0、引数は無料
function contribution(tok) {
// ...
if (tok.type === "pseudo-class") {
const name = tok.name;
if (ZERO_PSEUDO.has(name)) return { value: [0,0,0] }; // :where
if (MATCHES_ANY.has(name)) return { value: maxOfList(tok.args) }; // :is/:not/:has
// 通常の擬似クラス (:hover 等) は b を 1
return { value: [0,1,0] };
}
}
maxOfList は引数のセレクタリストを分割し、各々の詳細度を再帰計算して最大を返すだけ:
function maxOfList(listStr) {
let best = [0,0,0];
for (const sel of splitList(listStr)) {
const { value } = specificityOf(sel); // 再帰
if (compare(value, best) > 0) best = value;
}
return best;
}
結果はこうなる:
-
:is(#a, p)→1,0,0(#aと同じ) -
:where(#a, p)→0,0,0(ID が無料になる) -
a:is(.b)→0,1,1(a.bと同じ。:is自体は何も足さない)
テストで全部固定:
test(":is(#a, p) weighs like #a", () => assert.equal(spec(":is(#a, p)"), "1,0,0"));
test(":where(#a, .b, p) → (0,0,0)", () => assert.equal(spec(":where(#a, .b, p)"), "0,0,0"));
test("the :is() token itself adds nothing", () => {
assert.equal(spec("a:is(.b)"), "0,1,1"); // (0,2,1) ではない
});
test("nested :is keeps taking the max", () => {
assert.equal(spec(":is(:is(#deep), span)"), "1,0,0");
});
:where() は「リセット CSS やライブラリのデフォルトを、利用者が後から簡単に上書きできるように詳細度を 0 にしておく」ために使う。詳細度計算でここを取りこぼすと、:where で書かれたスタイルの強さを過大評価してしまう。
:nth-child(... of S) の小さな罠
:nth-child() は擬似クラスなので普通は b を 1 つ足す。だが of S 構文を取ると、擬似クラス分の 1 + S の最大詳細度になる:
const NTH_OF = new Set(["nth-child", "nth-last-child"]);
// ...
if (NTH_OF.has(name) && /\bof\b/i.test(tok.args)) {
const ofPart = tok.args.replace(/^[\s\S]*?\bof\b/i, ""); // "of" 以降
return { value: add([0,1,0], maxOfList(ofPart)) };
}
test("plain :nth-child is one pseudo-class", () => {
assert.equal(spec("li:nth-child(2n+1)"), "0,1,1");
});
test("of S adds the selector's specificity", () => {
assert.equal(spec(":nth-child(2 of .foo)"), "0,2,0"); // 擬似クラス + .foo
});
パースの地味な難所: カンマと括弧
セレクタリストはカンマで区切るが、:is(a, b) の中のカンマで区切ってはいけない。属性値 [data-x="a,b,c"] のカンマも同様。なので「トップレベルのカンマだけ」で分割する必要がある:
export function splitList(selector) {
const out = [];
let depthParen = 0, depthBracket = 0, quote = null, start = 0;
for (let i = 0; i < selector.length; i++) {
const ch = selector[i];
if (quote) { if (ch === quote && selector[i-1] !== "\\") quote = null; continue; }
if (ch === '"' || ch === "'") quote = ch;
else if (ch === "(") depthParen++;
else if (ch === ")") depthParen--;
else if (ch === "[") depthBracket++;
else if (ch === "]") depthBracket--;
else if (ch === "," && depthParen === 0 && depthBracket === 0) {
out.push(selector.slice(start, i).trim());
start = i + 1;
}
}
const last = selector.slice(start).trim();
if (last) out.push(last);
return out.filter(Boolean);
}
test("commas inside :is() stay together", () => {
assert.deepEqual(splitList(":is(a, b), c"), [":is(a, b)", "c"]);
});
test("commas inside [] do not split", () => {
assert.equal(analyze('[data-x="a,b,c"]').length, 1);
});
トークナイザ本体も同じ発想で、[...] と (...) をクオートとネストを尊重してまとめて 1 トークンとして読む。これで a[title="]"](属性値に閉じ括弧)のような意地悪な入力も壊れない。
勝敗の判定
複数セレクタを比較して「最優先」を一意に決める。同点なら CSS ではソース順が効くので、ここでは -1(同点)を返してその旨を表示する:
export function winnerIndex(results) {
let best = 0, tie = false;
for (let i = 1; i < results.length; i++) {
const cmp = compare(results[i].value, results[best].value);
if (cmp > 0) { best = i; tie = false; }
else if (cmp === 0) tie = true;
}
return tie ? -1 : best;
}
デモでは複数行に貼ったセレクタを並べ、勝者を緑でハイライトし、各セレクタをトークンに分解して「どの桁にいくつ足したか」を色付きチップで見せる。
設計
specificity.js — トークナイザ、トークン別の重み、リスト分割、
compare + winnerIndex (DOM-free, 42 tests)
app.js — textarea → (a,b,c) チップ + トークン分解のライブ表示
specificity.js は DOM 非依存なので 42 個のテストが Node で走る。インラインスタイルや !important はセレクタ詳細度の外側の概念(詳細度の比較より先に効く別レイヤー)なので、意図的に対象外にしている。
試してみる
- デモ: https://sen.ltd/portfolio/css-specificity-calculator/
- GitHub: https://github.com/sen-ltd/css-specificity-calculator
:is(#x) a と :where(#x) a を並べて貼ると、同じ見た目のセレクタが 1,0,1 と 0,0,1 に分かれるのが一目で分かる。:where がいかに「詳細度を捨てる」道具かが体感できる。
まとめ
- 詳細度は
(a, b, c)を左から比較。上位の桁が勝てば下位は無関係(1,0,0>0,99,99)。 -
*と結合子はゼロ。 -
hinge:
:is():not():has()は 引数の最大詳細度、:where()は常に 0。:name自身は足さない。 -
:nth-child(... of S)は擬似クラス分の 1 + S の最大。 - パースはトップレベルのカンマだけで分割し、
[...]/(...)はネストとクオートを尊重してまとめ読み。 - インラインや
!importantは詳細度の外側なので対象外。
これは SEN 合同会社の OSS ポートフォリオ #273 です。https://sen.ltd/portfolio/
