In CSS specificity, which parts of a selector land in the ID, class, and type columns — and which parts contribute nothing at all?
answer
- three buckets and one bin
- attributes ride with classes
- double colon does not mean heavier
- stars and combinators are free
- argument-taking pseudo-classes are transparent
basics
~10 sID selectors fill the first column. Class selectors, attribute selectors and pseudo-classes fill the second. Type selectors and pseudo-elements fill the third. The universal selector and all combinators contribute nothing.
solid answer
~40 sThe first column counts only ID selectors like `#hero`. The second counts class selectors, every form of attribute selector — `[disabled]`, `[href^="https"]`, `[type="text" i]` all score exactly one — and pseudo-classes such as `:hover` or `:nth-child(2)`. The third counts type selectors like `div` and pseudo-elements like `::before` or `::marker`, including the legacy single-colon spellings such as `:before`, which are pseudo-elements despite the syntax. Contributing nothing at all: the universal selector `*`, and the combinators — the descendant space, `>`, `+` and `~`. The pseudo-class rule has one wrinkle worth knowing: functional pseudo-classes that take a selector argument, like `:is()` and `:not()`, take the specificity of their most specific argument instead of counting as a plain class.
code
css · 6 lines#hero { } /* 1-0-0 */
[id="hero"] { } /* 0-1-0 */
input[type="text" i] { } /* 0-1-1 */
a:hover { } /* 0-1-1 */
a::before { } /* 0-0-2 */
ul > * li { } /* 0-0-2 */go deeper
Learn the three buckets by example: #id in the first, .class in the second, div in the third. Knowing that * and the combinators are free is enough at this level.
Be exact about the edge members: attribute selectors sit with classes, pseudo-elements sit with type selectors, and the operator inside an attribute selector never changes the score.
Expect to score a gnarly real-world selector out loud without hesitating, including the transparent pseudo-classes, and to explain which misfiled part caused a bug someone is showing you.
Frame it as a legibility problem: selectors whose weight is not obvious by looking at them are the ones that generate override wars, which is an argument for constraining selector shape team-wide.
## The buckets, precisely A selector's specificity is a triple. Scoring it is mechanical: walk the selector left to right and drop each simple selector into one of three buckets, or into the bin. ### Column A — ID selectors Only the `#name` form counts here. It is worth noting what does *not*: an attribute selector that happens to target the id attribute, `[id="hero"]`, is an attribute selector, so it scores in the class column, not the ID column. `#hero` is `1-0-0`; `[id="hero"]` is `0-1-0`, even though the two match the same element. ### Column B — classes, attributes, pseudo-classes Three different syntaxes share this bucket: - **Class selectors**: `.card`, `.is-open`. - **Attribute selectors**, in every form: presence (`[disabled]`), exact match (`[type="text"]`), and the substring operators `^=`, `$=`, `*=`, `~=`, `|=`. The operator makes no difference to the score, and neither does the case-insensitivity flag — `[type="text" i]` is still one. - **Pseudo-classes**: `:hover`, `:focus`, `:checked`, `:first-child`, `:nth-of-type(2n+1)`, `:root`. ```css .card { } /* 0-1-0 */ [data-state="open"] { } /* 0-1-0 */ input[type="text" i] { } /* 0-1-1 */ li:nth-child(2n+1) { } /* 0-1-1 */ ``` ### Column C — types and pseudo-elements Type (element) selectors — `div`, `a`, `input` — count here, and so do pseudo-elements: `::before`, `::after`, `::first-line`, `::marker`, `::placeholder`. Pseudo-elements landing in the *type* column, not the class column, is the distinction interviewers probe, because the double-colon syntax makes them look like heavier pseudo-classes. They are not: `a::before` is `0-0-2`, while `a:hover` is `0-1-1`, so the hover rule wins a conflict between them. The legacy single-colon spellings `:before`, `:after`, `:first-line` and `:first-letter` are still pseudo-elements and still score in the type column. The colon count is a syntax legacy, not a semantic difference. ### The bin — things that score zero - **The universal selector `*`.** It is defined to contribute nothing, so `* + *` has specificity `0-0-0`. - **All combinators**: the descendant space, the child combinator `>`, the next-sibling combinator `+`, the subsequent-sibling combinator `~`. They describe relationships between compound selectors; they are not themselves matched things. - **`:where()`**, a functional pseudo-class defined to always contribute zero specificity, along with everything inside it. ```css ul > * li { } /* 0-0-2 — only ul and li count */ * { } /* 0-0-0 */ ``` ## The functional-pseudo-class wrinkle "Pseudo-classes count as a class" is true for the plain ones but not for the functional ones that take a selector list as their argument. `:is()`, `:not()` and `:has()` are transparent to the score: they contribute the specificity of their most specific argument rather than a flat class point. So `:not(#hero)` scores `1-0-0`, not `0-1-0` — the parentheses hide an ID that still counts. `:where()` is the deliberate exception in the other direction, contributing zero no matter what it wraps. ## Why the boundaries matter in practice Most real specificity surprises come from a misfiled part rather than from the comparison itself. Someone writes `[id="menu"]` believing it is as strong as `#menu`; someone assumes `::before` outweighs `:hover` because of the extra colon; someone counts the `>` in `.a > .b` and gets `0-3-0` instead of `0-2-0`. Scoring the parts correctly is most of the work — comparing three small numbers is the easy half. ## How to say it in an interview Name the three buckets with an example each, then volunteer the zero list unprompted: universal selector, combinators, `:where()`. Volunteering the zero list is what separates a memorised answer from an understood one, because it shows you know the score comes from *matched simple selectors*, not from how much text the selector contains.
- Does `[type="checkbox"]` score differently from a bare `[type]`?No. Every attribute selector contributes exactly one to the class column regardless of form — presence, exact match, or any of the substring operators `^=`, `$=`, `*=`, `~=`, `|=`. The case-insensitivity flag `i` does not change it either. Only the number of attribute selectors in the compound matters, not what they test.
- How does the legacy single-colon `:before` count compared with `::before`?Identically. Both are the same pseudo-element and both score one in the type column; the double colon was introduced only to distinguish pseudo-elements from pseudo-classes in the syntax. The single-colon form is still parsed for legacy reasons, so `a:before` and `a::before` are both `0-0-2`.
- Why does `:not(#hero)` score as an ID rather than as a pseudo-class?Because `:not()` is transparent to specificity: it contributes the specificity of its most specific argument, not a flat class point. The ID inside the parentheses is what gets counted, giving `1-0-0`. The same rule applies to `:is()` and `:has()`.
saying these in an interview costs you the question
- Counts the > or + combinator as a specificity point
- Says [id="x"] is as specific as #x
- Puts pseudo-elements in the class column
- Claims the universal selector adds one to the type column
- Assumes [href^="https"] scores higher than a plain [href]