HTML & CSS NotesRohit’s interview study guide
Chapter 04

Selectors, Specificity & the Cascade

How CSS picks the elements a rule applies to, and how the browser decides which declaration wins when several rules set the same property on the same element.

11 topics
Parts marked Advanced are extra depth. Skip them on a first read or a quick revision.

#Selectors: type, class, id, attribute and universal

Added

A selector is a pattern the browser tests against every element; the rule's declarations apply to every element that matches. The building blocks are simple selectors: a type selector (p) matches by tag name, a class selector (.note) by a word in the class attribute, an id selector (#intro) by the id, an attribute selector ([data-state="open"]) by any attribute, and the universal selector (*) matches every element.

Simple selectors written without spaces form a compound selector, and every part must match the same element: a.btn[href] is "an <a>, with class btn, that has an href". A space between them would mean something completely different (a descendant, see Combinators).

Mental model: each simple selector is a filter, and a compound selector stacks filters on one element. In practice you style almost everything with classes: they're reusable, they say what a thing is in your design, and they keep specificity low and equal. Ids are unique per page and very specific, so they're better as link targets and JavaScript hooks than as styling hooks.

The five simple selectors and a compound one
CSS
* { box-sizing: border-box; }                    /* universal */
p { color: navy; }                               /* type */
.note { background: #fef9c3; }                   /* class */
#intro { font-weight: 700; }                     /* id */
[data-state="open"] { outline: 2px solid teal; } /* attribute */
a.btn[href] { color: crimson; }                  /* compound */
HTML
<p id="intro">Intro paragraph</p>
<p class="note">A note</p>
<div class="note" data-state="open">An open panel</div>
<a class="btn" href="#">A real link</a>
<a class="btn">An anchor without href</a>
Resultlive, in your browser
What’s happening
  1. * matches every element, so all five elements (and body's other descendants) compute box-sizing: border-box.
  2. p matches both paragraphs by tag name: both are navy, rgb(0, 0, 128).
  3. .note matches anything whose class list contains note, regardless of tag: the paragraph and the div both get the yellow background.
  4. #intro matches the one element with id="intro": font-weight 700.
  5. [data-state="open"] matches the div by its attribute: it gets the teal outline.
  6. a.btn[href] is a compound selector: all three conditions on one element. The first link matches and is crimson. The second <a> has the class but no href, so the compound fails and it keeps its normal colour.
AdvancedAttribute selector operators

Attribute selectors can do more than test equality. The operators match the value in different ways, and a trailing i makes the comparison case-insensitive:

SelectorMatches when the attribute…
[href]exists, with any value
[type="email"]is exactly email
[class~="warn"]is a space-separated list containing the word warn
[lang|="en"]is exactly en or starts with en-
[href^="https://"]starts with https://
[href$=".pdf"]ends with .pdf
[href*="example"]contains example anywhere
[data-size="large" i]equals large, ignoring case
Every operator on real markup
CSS
a[href^="https://"] { color: green; }
a[href$=".pdf"]     { color: crimson; }
a[href*="example"]  { font-style: italic; }
[class~="warn"]     { background: #fee2e2; }
[class="warn"]      { outline: 2px solid crimson; }
[lang|="en"]        { text-decoration: underline; }
[data-size="large" i] { font-size: 20px; }
HTML
<a id="ext" href="https://example.com/">external</a>
<a id="pdf" href="/files/cv.pdf">a PDF</a>
<p id="warn" class="box warn">class="box warn"</p>
<p id="gb" lang="en-GB">lang="en-GB"</p>
<p id="es" lang="es">lang="es"</p>
<p id="big" data-size="LARGE">data-size="LARGE"</p>
Resultlive, in your browser
What’s happening
  1. ^="https://" matches the external link (green); $=".pdf" matches the PDF link (crimson). These are handy for marking external links or file types.
  2. *="example" matches only the first link, whose URL contains "example": italic. The PDF link stays normal.
  3. [class~="warn"] matches class="box warn" because warn is one of the space-separated words, the same thing .warn does. [class="warn"] compares the whole string "box warn" and fails, so no outline.
  4. [lang|="en"] matches en-GB (it's designed for language codes) but not es.
  5. [data-size="large" i] matches LARGE because of the i flag: 20px.
AdvancedCase sensitivity and digit-first ids

In HTML documents, tag names are case-insensitive (DIV matches <div>), but class names and ids are case-sensitive (.Note doesn't match class="note"). And an id that starts with a digit is valid HTML, but #1st is not a valid CSS selector, so the whole rule is thrown away; you have to escape the digit.

Case and escaping
CSS
DIV.note { color: green; }        /* tag case doesn't matter */
.Note    { background: #fee2e2; } /* class case does */
#1st     { color: crimson; }      /* invalid selector: the whole rule is dropped */
#\31 st  { font-weight: 700; }    /* "\31 " is the escaped digit 1 */
HTML
<div class="note" id="n">class="note"</div>
<p id="1st">id="1st"</p>
Resultlive, in your browser
What’s happening
  1. DIV.note matches the lowercase <div>: tag names in HTML aren't case-sensitive, so it's green.
  2. .Note doesn't match class="note": the background stays transparent. Class and id matching is case-sensitive.
  3. #1st is a syntax error in CSS (an identifier can't start with a digit), so the browser drops the rule while parsing; it isn't even in document.styleSheets. The paragraph doesn't turn crimson.
  4. #\31 st writes the "1" as its code point (\31 plus a terminating space), which is valid, and it matches: weight 700.
  5. In JavaScript, CSS.escape("1st") returns \31 st, so document.querySelector("#" + CSS.escape(id)) is safe for any id.
AdvancedGotcha: one bad selector drops the whole list
A list with one unknown pseudo-class
CSS
h4, h5:not-a-real-thing, h6 { color: crimson; }
h4 { margin: 0; }
HTML
<h4>h4</h4><h6>h6</h6>
Resultlive, in your browser
What’s happening
  1. h5:not-a-real-thing is invalid: the browser doesn't know that pseudo-class.
  2. Because selector lists are parsed as one unit, the whole rule is dropped, so h4 and h6, which are perfectly valid, don't get crimson either.
  3. This is a real risk with selectors one browser supports and another doesn't, such as vendor-prefixed pseudo-elements like ::-webkit-… in a shared rule.
Which elements are crimson?
CSS
.card.featured { color: crimson; }
HTML
<div class="card featured">A</div>
<div class="card"><p class="featured">B</p></div>
<div class="featured card">C</div>
Show answer

A and C. .card.featured (no space) needs both classes on the same element; their order in the attribute doesn't matter.

The selector applied
CSS
.card.featured { color: crimson; }
HTML
<div class="card featured" id="a">A</div>
<div class="card"><p class="featured" id="b">B</p></div>
<div class="featured card" id="c">C</div>
Resultlive, in your browser
What’s happening
  1. A has both classes on one element: it matches.
  2. B's classes are split between a parent and a child. .card.featured doesn't look across elements, so neither matches. (.card .featured, with a space, would match the <p>.)
  3. C lists the classes in the other order. The class attribute is a set of words, so order is irrelevant: it matches.

#Combinators: descendant, child, sibling

Added

A combinator joins compound selectors and describes how the matched elements must be related in the DOM tree. There are four: the descendant combinator (a space: .menu a is any a anywhere inside .menu), the child combinator (>: .menu > li is an li whose parent is .menu), the next-sibling combinator (+: h3 + p is a p immediately after an h3), and the subsequent-sibling combinator (~: h3 ~ p is every p after an h3 with the same parent).

The selector always selects the element on the right; everything to its left is a condition about its ancestors or earlier siblings. So read .menu > li a right to left: "an a, inside an li, whose parent is .menu".

Mental model: the DOM is a family tree. A space means "descendant (child, grandchild, any generation)", > means "direct child only", + means "the very next younger sibling", and ~ means "any younger sibling". Combinators only look down the tree and forward among siblings, never up or back; looking up or back is what :has() was added for.

All four combinators on one tree
CSS
.menu a      { color: blue; }                 /* descendant */
.menu > li   { border-left: 4px solid teal; } /* child */
h3 + p       { color: crimson; }              /* next sibling */
h3 ~ p       { font-style: italic; }          /* subsequent siblings */
.menu, section { margin: 0 0 8px; }
HTML
<ul class="menu">
  <li id="top"><a href="#" id="shallow">Top link</a>
    <ul><li id="sub"><a href="#" id="deep">Nested link</a></li></ul>
  </li>
</ul>
<section>
  <p id="p0">before the heading</p>
  <h3>Heading</h3>
  <p id="p1">right after</p>
  <p id="p2">two after</p>
  <div>a div</div>
  <p id="p3">after the div</p>
</section>
Resultlive, in your browser
What’s happening
  1. .menu a (descendant) matches both links: the top one is a grandchild of .menu and the nested one is several levels deeper. Both are blue.
  2. .menu > li (child) matches only #top, whose parent is .menu. #sub's parent is the inner <ul>, so it gets no border (0px). This is the usual way to style only the first level of a nested menu.
  3. h3 + p matches only #p1, the element immediately after the h3. #p2 is two steps after, and #p0 comes before.
  4. h3 ~ p matches every later p sibling: #p1, #p2, and #p3 too, even though a <div> sits between. #p0 is before the heading, so it doesn't match: siblings only look forward.
AdvancedWhat counts as the next sibling

The sibling combinators only count elements. Text and comments between two elements don't break h3 + p, but any element in between does.

Text, a comment and an element in between
CSS
h3 + p { color: crimson; }
h3 { margin: 0; }
HTML
<section><h3>A</h3> some loose text <!-- a comment --> <p id="a">p after text</p></section>
<section><h3>B</h3><span>x</span><p id="b">p after a span</p></section>
Resultlive, in your browser
What’s happening
  1. In the first section, loose text and a comment sit between the h3 and the p. They're nodes, but not elements, so the p is still the h3's next element sibling: crimson.
  2. In the second, a <span> sits between them, so the p's previous element sibling is the span, and h3 + p doesn't match.
  3. When a design says "the paragraph after a heading", a wrapper element added later (say, for an icon) silently breaks +. :has() or a class is more robust.
AdvancedDescendant selectors and nested themes

.dark a matches any link with a .dark ancestor, no matter how far away, and it says nothing about which ancestor is closest. With nested themes this goes wrong: a .light box inside a .dark page is matched by both .dark a and .light a, which have the same specificity, so the rule written last wins, not the nearer theme.

A light panel inside a dark one
CSS
.light a { color: navy; }
.dark a  { color: gold; }
.dark  { background: #1e293b; padding: 8px; }
.light { background: #f8fafc; padding: 8px; }
HTML
<div class="dark">
  <a href="#" id="d">dark link</a>
  <div class="light"><a href="#" id="l">light link inside dark</a></div>
</div>
Resultlive, in your browser
What’s happening
  1. #d is only inside .dark, so it's gold.
  2. #l is inside .light and inside .dark, so both rules match it. Both have specificity 0-1-1.
  3. With equal specificity, the later rule wins: .dark a comes second, so the link inside the light panel is gold, on a light background. The nearer ancestor didn't matter.
  4. Fixes: write the more specific .dark .light a, use > where the structure allows, use custom properties (which inherit from the nearest ancestor), or use @scope, which adds a "nearest scope wins" step to the cascade (see Scoping styles with @scope).
Which items are crimson?
CSS
ul > li + li { color: crimson; }
HTML
<ul>
  <li>one</li>
  <li>two</li>
  <li>three <ul><li>nested a</li><li>nested b</li></ul></li>
</ul>
Show answer

"two", "three" and "nested b" (and "nested a" inherits crimson from "three", because color is inherited).

The rule applied
CSS
ul > li + li { color: crimson; }
li { color: black; } /* stops inheritance so only real matches turn crimson */
HTML
<ul>
  <li id="one">one</li>
  <li id="two">two</li>
  <li id="three">three <ul><li id="na">nested a</li><li id="nb">nested b</li></ul></li>
</ul>
Resultlive, in your browser
What’s happening
  1. Read right to left: an li whose previous element sibling is an li, and whose parent is a ul.
  2. "one" has no previous sibling: no match. "two" and "three" each follow an li: match.
  3. The inner list follows the same logic: "nested a" is first (no match), "nested b" follows it (match). The ul > part is satisfied by the inner ul too; > doesn't mean "the outer list".
  4. The demo adds li { color: black } so only true matches are crimson. In the question's version, "nested a" would inherit crimson from "three", which is why "the element is red" and "the selector matched" aren't the same question.
  5. li + li is a well-known way to add a separator or spacing between items without touching the first one.

#Pseudo-classes

Added

A pseudo-class is a keyword after a colon that selects elements by state or position rather than by anything written in the markup: :hover, :focus-visible, :checked, :disabled, :first-child, :nth-child(2n), :empty, :root. They describe facts the browser knows and the HTML doesn't spell out: where an element sits among its siblings, whether a checkbox is ticked, whether the pointer is over it.

They fall into groups. Structural: :first-child, :last-child, :only-child, :nth-child(), :nth-of-type(), :empty, :root. User action: :hover, :active, :focus, :focus-visible, :focus-within. Form state: :checked, :disabled, :required, :invalid, :user-invalid, :placeholder-shown. Links: :link, :visited, :any-link. Logical: :is(), :where(), :not(), :has() (their own topic: :is(), :where(), :not() and :has()).

Mental model: a pseudo-class is a class the browser adds and removes for you as things change. That's also how they count for specificity: exactly like a class, 0-1-0.

Structural pseudo-classes on a list
CSS
li { padding: 2px 6px; }
li:first-child   { color: green; }
li:last-child    { color: crimson; }
li:nth-child(odd) { background: #e2e8f0; }
li:nth-child(3n) { font-weight: 700; }
HTML
<ol>
  <li>one</li><li>two</li><li>three</li>
  <li>four</li><li>five</li><li>six</li>
</ol>
Resultlive, in your browser
What’s happening
  1. :first-child matches "one", the first child of its parent: green. :last-child matches "six": crimson.
  2. :nth-child(odd) (the same as 2n+1) matches positions 1, 3 and 5: they get the grey background. Positions count from 1, not 0.
  3. :nth-child(3n) matches positions where 3n gives a whole n ≥ 0: 3 and 6 are bold.
  4. Nothing in the HTML marks these items; the browser works out the position and would update the styles at once if an item were added or removed.
Advancednth-child vs nth-of-type

p:nth-child(2) means "an element that is the 2nd child of its parent, and is a p". It doesn't mean "the 2nd p". If the 2nd child is an h3, nothing matches. :nth-of-type(2) counts only siblings of the same tag.

Second child vs second paragraph
CSS
p:nth-child(2)   { color: crimson; }
p:nth-of-type(2) { text-decoration: underline; }
HTML
<article>
  <h3 id="h">heading (child 1)</h3>
  <p id="a">first p (child 2)</p>
  <p id="b">second p (child 3)</p>
</article>
Resultlive, in your browser
What’s happening
  1. Children are numbered by position among all element siblings: the h3 is 1, the paragraphs are 2 and 3.
  2. p:nth-child(2) matches #a, the element in position 2, which happens to be the first paragraph: crimson.
  3. p:nth-of-type(2) numbers only the p siblings, so it matches #b, the second paragraph: underlined.
  4. Many "my nth-child doesn't work" bugs are a heading or wrapper shifting the count; :nth-of-type or the of S syntax below fixes it.
AdvancedAn+B formulas and the of S filter

An+B matches every position that equals A×n + B for some n = 0, 1, 2…. -n+3 is "the first three", n+4 is "the fourth onwards", and :nth-last-child() counts from the end. :nth-child(2 of .done) counts only the siblings that match .done. caniuse lists the of S syntax in Chrome 111, Firefox 113 and Safari 9, so it's in all three engines since 2023.

Formulas on a list of six
CSS
li:nth-child(-n+3)     { color: green; }
li:nth-child(n+4)      { color: crimson; }
li:nth-last-child(2)   { font-weight: 700; }
li:nth-child(2 of .done) { text-decoration: underline; }
HTML
<ul>
  <li class="done">1 done</li><li>2</li><li class="done">3 done</li>
  <li>4</li><li class="done">5 done</li><li>6</li>
</ul>
Resultlive, in your browser
What’s happening
  1. -n+3 gives 3, 2, 1, 0, -1… for n = 0, 1, 2…; positions 1, 2 and 3 exist, so the first three are green.
  2. n+4 gives 4, 5, 6…: items 4 to 6 are crimson.
  3. :nth-last-child(2) counts from the end: item 5 of 6 is bold.
  4. :nth-child(2 of .done) first filters the siblings to those matching .done (items 1, 3, 5), then takes the 2nd of them: item 3. li.done:nth-child(2) would match nothing, because the 2nd child (item 2) isn't .done. This is the fix for zebra-striping a list with hidden rows: :nth-child(odd of :not([hidden])).
AdvancedForm-state pseudo-classes

Form pseudo-classes reflect live control state: :checked, :disabled, :required, :placeholder-shown (the field is empty and showing its placeholder). Paired with a sibling combinator, :checked gives you a toggle with no JavaScript. For validation styling, prefer :user-invalid over :invalid (see Styling valid and invalid fields).

Pseudo-classes from control state
CSS
input:checked + label   { color: green; font-weight: 700; }
input:disabled          { opacity: 0.5; }
input:required          { border-left: 4px solid crimson; }
input:placeholder-shown { background: #fef9c3; }
.toggle:checked ~ .panel { display: block; }
.panel { display: none; }
HTML
<input type="checkbox" id="terms" checked><label for="terms" id="lt">I agree</label>
<input type="checkbox" id="news"><label for="news" id="ln">Newsletter</label>
<p><input id="name" required placeholder="Name"> <input id="off" disabled value="locked"></p>
<input type="checkbox" class="toggle" id="more" checked><label for="more">Show more</label>
<div class="panel" id="panel">More content</div>
Resultlive, in your browser
What’s happening
  1. input:checked + label styles the label right after a ticked checkbox: "I agree" is bold green, "Newsletter" (unticked) stays 400.
  2. :disabled and :required follow the attributes: opacity 0.5, a 4px crimson left border.
  3. :placeholder-shown matches while the field is empty: yellow. After the test sets a value, the placeholder is gone and so is the yellow.
  4. .toggle:checked ~ .panel shows the panel while the box is ticked (block). When the test unticks it, the panel's display becomes none immediately; the pseudo-class re-evaluates as the state changes. (Click "Show more" in the live result to try it.)
  5. For real disclosure widgets prefer <details>, which is accessible out of the box; the checkbox trick is still asked about in interviews.
Advanced:empty, :root and whitespace

:empty matches elements with no children at all, and in Chrome 155 a single space counts as a child. MDN notes that Selectors Level 4 changes :empty to ignore whitespace-only content, but no browser supports that yet. :root matches the document's root element (<html>), but with class-level specificity, which is why custom properties are usually declared on :root.

:empty with and without whitespace
CSS
.box { min-height: 10px; margin-bottom: 4px; background: #e2e8f0; }
.box:empty { background: #fecaca; }
:root { --accent: teal; }
html  { --accent: crimson; }
.box  { color: var(--accent); }
HTML
<div class="box" id="none"></div>
<div class="box" id="space"> </div>
<div class="box" id="comment"><!-- only a comment --></div>
Resultlive, in your browser
What’s happening
  1. #none has no child nodes: :empty matches, red background.
  2. #space contains one space, a text node, so in Chrome 155 it doesn't match. Templating languages often leave whitespace inside "empty" elements, which is why :empty seems not to work.
  3. #comment contains only a comment, which doesn't count: it matches.
  4. :root (0-1-0) beats html (0-0-1) even though the html rule comes later, so --accent is teal.
nth-child or nth-of-type?
CSS
.list p:nth-child(1) { color: crimson; }
HTML
<div class="list">
  <h4>Title</h4>
  <p>First paragraph</p>
  <p>Second paragraph</p>
</div>

Which paragraph is crimson?

Show answer

Neither. The first child of .list is the h4, so no p is :nth-child(1). Use p:first-of-type or p:nth-of-type(1).

The selector applied
CSS
.list p:nth-child(1) { color: crimson; }
.list p:first-of-type { text-decoration: underline; }
h4 { margin: 0; }
HTML
<div class="list"><h4>Title</h4><p id="a">First paragraph</p><p id="b">Second paragraph</p></div>
Resultlive, in your browser
What’s happening
  1. p:nth-child(1) requires position 1 among all siblings and being a p. Position 1 is the h4, so nothing matches.
  2. p:first-of-type counts only p siblings, so it matches the first paragraph (the demo underlines it to show that).

#Pseudo-elements: ::before, ::after and friends

Added

A pseudo-element selects a part of an element, or creates a box that isn't in the HTML at all. ::before and ::after insert a generated box as the first or last child of the element; ::first-line and ::first-letter style part of the text; ::marker is a list bullet, ::placeholder an input's placeholder, ::selection the highlighted text, ::backdrop the layer behind a modal <dialog>, ::file-selector-button the button inside a file input.

They're written with two colons to tell them apart from pseudo-classes. The four from CSS 2 (:before, :after, :first-line, :first-letter) still work with one colon for compatibility. A pseudo-element counts like a type selector in specificity (0-0-1), and it goes at the end of the selector.

Mental model: a pseudo-class is an adjective ("the hovered link"), a pseudo-element is a noun ("the first letter of the paragraph"). ::before and ::after in particular are decorations stuck to the inside of an element: they're laid out and painted like children, but they aren't in the DOM, so JavaScript can't select them and their text isn't selectable.

Generated content with ::before and ::after
CSS
.req::after { content: " *"; color: crimson; }
.badge::before {
  content: "";
  display: inline-block;
  width: 10px;
  height: 10px;
  margin-right: 6px;
  border-radius: 50%;
  background: green;
}
HTML
<label class="req" id="email">Email</label>
<p><span class="badge" id="b">Online</span></p>
<p><span id="plain">Online</span></p>
Resultlive, in your browser
What’s happening
  1. .req::after adds the text " *" after the label's content, in crimson. getComputedStyle(el, "::after") is how you read a pseudo-element's styles: its content is " *" and its colour rgb(220, 20, 60).
  2. The label still has exactly one child node, its text "Email": the asterisk isn't in the DOM. document.querySelector(".req::after") returns null, because pseudo-elements never match a DOM query.
  3. .badge::before has content: "" (empty but present), which is enough to create the box. As an inline-block it can have a size: a 10 × 10 green dot.
  4. The dot really occupies the line: the badge span is 16px wider than the identical plain span (10px dot plus 6px margin).
  5. This is the typical use: icons, dots, quote marks, required markers and decorative shapes that shouldn't clutter the HTML.
AdvancedNo content, no box

A ::before or ::after box is created only when content is set to something other than none or normal (its initial value). Width, height and a background are not enough. This is the most common reason a pseudo-element "doesn't work".

The same styles with and without content
CSS
.box { width: 200px; background: #f1f5f9; margin-bottom: 8px; }
.box::before { display: block; height: 40px; background: #a5b4fc; }
.with::before { content: ""; }
HTML
<div class="box" id="without"></div>
<div class="box with" id="with"></div>
Resultlive, in your browser
What’s happening
  1. Both boxes give ::before a block display, a height and a background.
  2. #without never sets content, so its computed content is none, no pseudo-element box is generated, and the parent is 0px tall.
  3. #with adds content: "", and the 40px block appears.
AdvancedReplaced elements get no ::before

::before and ::after are inserted inside the element, before or after its content. Replaced elements such as <img> and <input> render their content from outside the CSS box model, so generated content on them doesn't render. Wrap the control in a <span> or style its <label> instead.

A block ::before on a div and on an input
CSS
.d::before, .i::before { content: ""; display: block; height: 50px; background: #fca5a5; }
.d, .i { width: 120px; }
HTML
<div class="d" id="d"></div>
<input class="i" id="i"><input id="plain" style="width: 120px">
Resultlive, in your browser
What’s happening
  1. On the div, the 50px block ::before is laid out as the first child: the div is 50px tall.
  2. On the input, the same rule changes nothing: the input is exactly as tall as a plain input. (Chrome still reports computed styles for #i::before, content: "" and height: 50px, but no box is rendered.)
  3. That's why custom checkboxes put the decoration on the label::before or on a wrapper, not on the input.
AdvancedScreen readers and generated text

Text in content is not in the DOM, but it can be in the accessibility tree: in Chrome 155 it becomes part of an element's accessible name. MDN warns that some browser and screen reader combinations don't announce it, so never put essential information only in CSS. You can give generated content alternative text after a slash, which replaces it for assistive technology: content: "★" / "Favourite". caniuse lists that syntax in Chrome 77, Firefox 128 and Safari 17.4.

Generated text in the accessible name
CSS
.fav::before   { content: "★ "; }
.alt::before   { content: "★ " / "Favourite"; }
.quiet::before { content: "★ " / ""; }
HTML
<button class="fav" id="a">Save</button>
<button class="alt" id="b">Save</button>
<button class="quiet" id="c">Save</button>
Resultlive, in your browser
What’s happening
  1. Without alt text, Chrome includes the star character in the button's accessible name: "★ Save". A screen reader might read "black star, Save".
  2. With / "Favourite", the alternative text is used instead: "Favourite Save".
  3. With / "", the decoration is ignored: "Save". That's the right choice for purely decorative icons.
AdvancedThe other pseudo-elements

The rest style parts of things that are already there. Only a limited set of properties applies to each (for example ::marker takes colour, font and content, but not margin). ::selection only shows when you select text in the live result; ::backdrop belongs with modal dialogs (see Built-in interactive elements).

Drop cap, marker, placeholder and selection
CSS
.story::first-letter { font-size: 40px; float: left; line-height: 1; margin-right: 4px; color: teal; }
.story::first-line   { font-weight: 700; }
li::marker           { color: crimson; content: "→ "; margin-left: 30px; /* ignored */ }
input::placeholder   { color: #64748b; font-style: italic; }
::selection          { background: #fde68a; }
.old:before          { content: "single colon still works "; }
HTML
<p class="story" id="s">Once upon a time, the box model had two sizing modes and everyone argued about which was right.</p>
<ul><li id="li">Marker with an arrow</li></ul>
<input id="in" placeholder="Search">
<p class="old" id="old">.</p>
Resultlive, in your browser
What’s happening
  1. ::first-letter makes a floated, 40px teal drop cap from the "O"; ::first-line makes the first rendered line bold, however wide the viewport makes it.
  2. li::marker recolours the bullet and replaces it with an arrow: colour rgb(220, 20, 60), content "→ ". Its margin-left: 30px is ignored (computed 0px), because margin isn't one of the properties ::marker accepts.
  3. input::placeholder is italic grey; ::selection turns selected text yellow (select some text in the live result).
  4. .old:before with one colon still works for the four CSS 2 pseudo-elements; newer ones like ::marker require two.
Why is there no star?
CSS
.rating::before { color: gold; font-size: 20px; }
.rating::after  { content: "★★★"; }

.rating shows three stars after its text, but nothing before it. Why?

Show answer

::before has no content, so no box is generated. Styling it does nothing until content is set (to a string, "", an image, a counter…).

What’s happening
  1. ::before and ::after start with content: normal, which for them computes to none: no box.
  2. .rating::after sets content: "★★★", so that box exists and shows the stars.
  3. .rating::before only sets colour and size, so it never exists. The no-content demo above shows the same thing: computed content: none and zero height.

#Specificity

Added

Specificity is how the browser ranks selectors when two rules of the same origin and layer set the same property on the same element. Each selector gets a score of three numbers, usually written A-B-C: A counts id selectors, B counts classes, attribute selectors and pseudo-classes, and C counts type selectors and pseudo-elements. The universal selector * and combinators count nothing.

Scores are compared column by column, left to right, like version numbers, not added up as a single number: 1-0-0 beats 0-15-0, because the first column is decided before the second is even looked at. If all three columns tie, the rule that comes later wins (see The cascade).

Mental model: specificity is how precisely a rule points at an element. "Paragraphs" (p) is vague, "notes" (.note) is better, "the intro" (#intro) is exact, and the more precise instruction wins. Inline style attributes aren't selectors at all and beat every selector; !important is a separate step again (see !important).

SelectorSpecificity
*0-0-0
li0-0-1
ul li::marker0-0-3
.note0-1-0
[type="email"]0-1-0
a:hover0-1-1
nav .menu > li.active0-2-2
#intro1-0-0
#intro .note p1-1-1
:where(#intro .note)0-0-0
:is(#intro, p)1-0-0
Four rules, one property
CSS
#intro     { color: crimson; } /* 1-0-0 */
p.note.big { color: purple; }  /* 0-2-1 */
.note      { color: green; }   /* 0-1-0 */
p          { color: navy; }    /* 0-0-1 */
HTML
<p id="intro" class="note big">matched by all four rules</p>
<p class="note big">matched by p.note.big, .note and p</p>
<p class="note">matched by .note and p</p>
<p>matched by p only</p>
Resultlive, in your browser
What’s happening
  1. The first paragraph matches all four rules. #intro is 1-0-0; the others have 0 in the id column, so the id rule wins on the first column alone: crimson.
  2. The second has no id. p.note.big is 0-2-1 and .note is 0-1-0: the class column decides (2 beats 1), purple.
  3. The third matches .note (0-1-0) and p (0-0-1): one class beats one type, green.
  4. The last one only matches p: navy.
  5. Source order didn't matter anywhere here: the more specific rules are written first and still win. Order only breaks ties.
AdvancedSpecificity is not a decimal number

Old articles described specificity as a number like "100 for an id, 10 for a class". That's wrong: the columns never carry over. No number of classes adds up to an id.

Eleven classes against one id
CSS
#x { color: green; }
.a.a.a.a.a.a.a.a.a.a.a { color: crimson; } /* 0-11-0 */
.b.b { color: navy; }                       /* 0-2-0: a repeated class counts twice */
.b   { color: crimson; }
HTML
<p id="x" class="a">id against eleven classes</p>
<p class="b">a doubled class</p>
Resultlive, in your browser
What’s happening
  1. .a.a.a.a.a.a.a.a.a.a.a repeats the same class 11 times. Each repetition counts, so it's 0-11-0, and it still matches the element (which only needs the class once).
  2. #x is 1-0-0. The id column is compared first, 1 beats 0, and the 11 classes never get a say: green.
  3. Repeating a class is a legitimate, if ugly, way to raise specificity without adding an id or !important: .b.b (0-2-0) beats the later .b (0-1-0), so the paragraph is navy.
AdvancedEach selector in a list scores separately

In h1, #hero { … }, each selector has its own specificity, and an element uses the score of the selector that matched it. The id in the list doesn't make the rule strong for elements that matched through h1.

A list containing an id
CSS
.title, #hero { color: crimson; }
.headline     { color: navy; }
HTML
<h2 class="title headline" id="t">matched through .title (0-1-0)</h2>
<h2 class="headline" id="hero">matched through #hero (1-0-0)</h2>
Resultlive, in your browser
What’s happening
  1. The first heading matches the list through .title, so that rule applies to it with 0-1-0. .headline is also 0-1-0 and comes later: navy.
  2. The second heading matches through #hero, so the same rule applies to it with 1-0-0, which beats .headline: crimson.
  3. :is(.title, #hero) would behave differently: :is() takes the specificity of its most specific argument for every element it matches (see :is(), :where(), :not() and :has()).
AdvancedInline styles beat every selector

Declarations in a style attribute aren't selectors and have no specificity; the cascade puts them in their own step that comes before specificity, so they beat any selector, even several ids. Only !important (or a declaration from a higher origin or layer) beats an inline style.

An inline style against an id
CSS
#a#a#a { color: crimson; } /* 3-0-0 */
HTML
<p id="a" style="color: teal">inline style</p>
Resultlive, in your browser
What’s happening
  1. #a#a#a repeats the id three times: 3-0-0, as specific as a selector gets in practice.
  2. The style attribute still wins: teal. Style attributes are compared before specificity is considered.
  3. That's why overriding a library that sets inline styles needs !important, and why frameworks that write inline styles (for animation, say) can be hard to override from a stylesheet.
Which colour wins?
CSS
div p.lead        { color: crimson; } /* rule 1 */
.article .lead    { color: green; }   /* rule 2 */
body div.article p { color: navy; }   /* rule 3 */
HTML
<div class="article"><p class="lead">Text</p></div>
Show answer

Green. Rule 2 is 0-2-0, rule 3 is 0-1-3, rule 1 is 0-1-2.

The three rules
CSS
div p.lead         { color: crimson; }
.article .lead     { color: green; }
body div.article p { color: navy; }
HTML
<div class="article"><p class="lead">Text</p></div>
Resultlive, in your browser
What’s happening
  1. No rule has an id, so the first column ties at 0.
  2. Classes: rule 1 has .lead (1), rule 2 has .article and .lead (2), rule 3 has .article (1). Rule 2 wins on the second column.
  3. Rule 3 has the most type selectors (body, div, p = 3), but the third column only matters when the first two tie, and they don't.
  4. So the paragraph is green, even though rule 3 comes last and "looks" the most detailed.

#The cascade: how the browser picks a winner

Added

For every element and every property, the browser collects all the declarations that apply and has to choose one. The algorithm that does this is the cascade, and it works per property, not per rule: one rule can win color while another rule wins padding on the same element.

The declarations are compared in a fixed order of criteria, and the first criterion that separates them decides:

  1. Origin and importance. From weakest to strongest: browser (user-agent) styles, user styles, author (your) styles, CSS animations, author !important, user !important, browser !important, and finally CSS transitions.
  2. Context. Styles inside a shadow tree versus outside it (relevant for web components).
  3. Element-attached styles. A style attribute beats any selector.
  4. Cascade layers. Declarations in later layers beat earlier ones; unlayered styles beat all layers (see Cascade layers with @layer).
  5. Specificity (see Specificity).
  6. Scope proximity. For @scope rules, the nearer scope root wins, and any scoped rule beats an unscoped one (see Scoping styles with @scope).
  7. Order of appearance. The last declaration wins.

Mental model: a knockout tournament. Every declaration for color enters round one (origin and importance); only the winners of that round go on, and so on. Specificity, the step everyone talks about, is round five: it only breaks ties left over from the earlier rounds.

Three properties, three different winners
CSS
.card { color: navy; background: #e0f2fe; padding: 8px; }
#special { color: crimson; }
article .card { background: #fef9c3; }
.card { padding: 16px; }
HTML
<article>
  <div class="card" id="special">One element, three winning rules</div>
</article>
Resultlive, in your browser
What’s happening
  1. Four rules match the div. The cascade runs separately for each property.
  2. color: the first .card (0-1-0) and #special (1-0-0) compete. Same origin, no layers, no inline style, so specificity decides: crimson.
  3. background: the first .card (0-1-0) and article .card (0-1-1) compete: article .card wins on the type column, yellow.
  4. padding: the two .card rules compete, both 0-1-0. Specificity ties, so order of appearance decides: the later one gives 16px.
  5. So "which rule wins?" is the wrong question; ask "which declaration wins for this property?". DevTools shows exactly this in the Styles pane, with the losing declarations struck through.
AdvancedOrigin beats specificity

Origin is decided before specificity. The browser's own stylesheet styles links with a selector like a:-webkit-any-link (0-1-1 in Chrome), but a plain a (0-0-1) in your stylesheet still beats it, because author styles beat user-agent styles before specificity is compared.

A type selector against the browser's link style
CSS
a { color: green; }
HTML
<a href="#" id="styled">styled link</a>
Resultlive, in your browser
What’s happening
  1. Without your rule, the browser's stylesheet colours the link with its link colour, using a selector more specific than a.
  2. Your a { color: green } is in the author origin, which is compared first: green wins without needing any extra specificity.
  3. That's also why your styles never need to "fight" browser defaults like heading margins or list padding: any author declaration beats them.
AdvancedAnimations sit above normal declarations

A running CSS animation is its own origin, stronger than every normal author declaration (even an id or an inline style), but weaker than !important. The demo pauses an animation at its start so its value can be read.

An animation against an id and against !important
CSS
@keyframes tint { from, to { color: teal; } }
#a { color: crimson; }
.anim { animation: tint 100s paused; }
#b { color: navy !important; }
HTML
<p id="a" class="anim">animated, against an id</p>
<p id="b" class="anim">animated, against !important</p>
Resultlive, in your browser
What’s happening
  1. Both paragraphs run the tint animation, paused at its start, so its keyframe value teal applies for the whole 100 seconds.
  2. #a also has color: crimson from an id rule, a normal author declaration. The animation origin is stronger: teal.
  3. #b has color: navy !important. Important author declarations rank above animations: navy.
  4. In the spec's order, running transitions sit above everything, even !important declarations (not tested here, because a test can't run a real transition over time).
AdvancedInvalid declarations are thrown away

A declaration the browser can't parse (unknown property, bad value) is dropped as if it weren't there, so the previous declaration still applies. That's the classic fallback pattern: write the safe value first, the newer one second. But a declaration that uses var() can only be checked later, at computed-value time; if the variable's value turns out to be invalid for the property, the property becomes unset (inherited or initial), and the earlier declaration does not come back. More in Custom properties.

Two kinds of invalid
CSS
.parent { color: navy; }
.a { color: crimson; color: not-a-colour; }
.b { --size: 20px; color: crimson; color: var(--size); }
HTML
<div class="parent">
  <p class="a">invalid when parsed</p>
  <p class="b">invalid when computed</p>
</div>
Resultlive, in your browser
What’s happening
  1. In .a, color: not-a-colour fails to parse, so it's discarded and the earlier color: crimson is the only one left: crimson.
  2. In .b, color: var(--size) parses fine (the browser can't know yet what the variable holds). Crimson loses on order of appearance.
  3. At computed-value time var(--size) becomes 20px, which isn't a colour. The property becomes unset; color is inherited, so it takes the parent's navy. The crimson declaration is already gone.
  4. That's why @supports or a fallback inside var(--x, fallback) is needed for variable-based progressive enhancement, and why a typo in a custom property can make text turn the parent's colour.
AdvancedOrder across stylesheets

"Later wins" applies to the whole document order: rules in a later <style> or <link rel="stylesheet"> come after earlier ones. That's why your stylesheet must be linked after a third-party one if you want equal-specificity rules to override it.

Two style elements
CSS
.btn { background: #bbf7d0; padding: 6px 10px; border: 0; }
HTML
<style>.btn { background: #fecaca; }</style>
<button class="btn" id="b">Save</button>
Resultlive, in your browser
What’s happening
  1. The demo's CSS block is a <style> in the head; the HTML adds a second <style> in the body, which comes later in the document.
  2. Both .btn rules are 0-1-0, so order decides and the later, pink one wins.
  3. In a real app the same thing happens with <link> order and with the order a bundler concatenates CSS files, which is a common reason styles differ between dev and production builds.
Predict the cascade
CSS
p { color: green !important; }
#intro { color: crimson; }
HTML
<p id="intro" style="color: navy">Hello</p>

What colour is the paragraph?

Show answer

Green. Importance is decided before inline styles and specificity.

The rules applied
CSS
p { color: green !important; }
#intro { color: crimson; }
HTML
<p id="intro" style="color: navy">Hello</p>
Resultlive, in your browser
What’s happening
  1. Round one is origin and importance. p { color: green !important } is an important author declaration; the id rule and the inline style are normal author declarations.
  2. Important author beats normal author, so the other two are knocked out before the "inline style" and "specificity" rounds are reached.
  3. Result: green, from the least specific selector on the page.

#Inheritance and the keywords inherit, initial, unset and revert

Added

Inheritance is how an element gets a value for a property that no rule sets on it: for inherited properties, it takes its parent's computed value. Mostly these are text properties: color, font-*, line-height, letter-spacing, text-align, visibility, cursor, list-style. Box properties (margin, padding, border, background, width, display, position) are not inherited: an element without a rule gets the property's initial value instead.

The crucial detail: inheritance only happens when the cascade found nothing for that property on the element. An inherited value has no specificity at all, so any rule that matches the element, even *, beats it. And what's inherited is the parent's computed value, which is why line-height: 150% and line-height: 1.5 inherit differently (see CSS units).

Mental model: inheritance is the family default. If nobody told you what colour to wear, you dress like your parent. The moment any rule addresses you directly, the family default is ignored. Four keywords let you ask for a specific default explicitly: inherit (take the parent's value), initial (the property's spec default), unset (inherit if the property is inherited, else initial), and revert (go back to what the browser's stylesheet would give).

What a child inherits from a styled parent
CSS
.panel {
  color: teal;
  font-style: italic;
  border: 2px solid teal;
  padding: 12px;
  background: #f0fdfa;
}
HTML
<div class="panel">
  <p id="child">I'm a child of .panel</p>
</div>
Resultlive, in your browser
What’s happening
  1. No rule targets the <p>'s color or font-style, and both are inherited properties, so it takes the panel's computed values: teal and italic.
  2. border and padding aren't inherited. The <p> gets their initial values: a 0px border and 0px padding. Imagine if they were inherited: every descendant would draw its own border.
  3. background-color isn't inherited either: the child's is transparent (rgba(0, 0, 0, 0)). It looks like the panel's colour only because the panel's background shows through it.
  4. This is why you set font-family and color once on body and every element follows, while spacing has to be set per element.
AdvancedInherited values lose to any rule

Inheritance is the fallback when the cascade is empty. Any matching declaration beats it, no matter how strong the parent's rule was. The browser's own stylesheet counts too: links and form controls have browser styles for color and font, so they don't inherit those from your text.

An id on the parent against a type selector on the child
CSS
#parent { color: crimson; font-size: 18px; }
p { color: navy; }
.fixed a, .fixed button { color: inherit; font: inherit; }
HTML
<div id="parent">
  <p id="p">targeted by p</p>
  <span id="s">not targeted</span>
  <a href="#" id="a">a link</a> <button id="b">a button</button>
</div>
<div id="parent2" class="fixed" style="color: crimson; font-size: 18px">
  <a href="#" id="a2">fixed link</a> <button id="b2">fixed button</button>
</div>
Resultlive, in your browser
What’s happening
  1. #parent is 1-0-0, but that specificity belongs to the parent's own declaration. For the <p>, the only candidate is p { color: navy } (0-0-1), which beats an inherited value every time: navy.
  2. The span has no rule for color, so it inherits crimson.
  3. The link has a browser rule for color, and the button has browser rules for font, so neither inherits: the button's text stays at Chrome's 13.3333px.
  4. In the second group, color: inherit and font: inherit explicitly ask for the parent's values: the link is crimson and the button 18px. Most resets include button, input, select, textarea { font: inherit; } for this reason.
Advancedinherit, initial, unset and revert

Every property accepts these. The confusing one is initial: it's the CSS spec's initial value, not the browser's default for that element. The initial value of display is inline, so display: initial on a <div> makes it inline. revert is usually what people actually want: the value the browser's stylesheet would give.

The keywords on a div and a heading
CSS
.parent { color: teal; border: 2px solid teal; padding: 4px; }
.d-initial { display: initial; }
.d-revert  { display: revert; }
.c-unset   { color: unset; border: unset; }
.c-initial { color: initial; }
h2 { font-size: 40px; margin: 0; }
h2.r { font-size: revert; }
HTML
<div class="parent">
  <div class="d-initial" id="di">display: initial</div>
  <div class="d-revert" id="dr">display: revert</div>
  <p class="c-unset" id="cu">color and border: unset</p>
  <p class="c-initial" id="ci">color: initial</p>
</div>
<h2 id="big">h2 at 40px</h2>
<h2 class="r" id="rev">h2 reverted</h2>
Resultlive, in your browser
What’s happening
  1. display: initial on a div gives inline, the spec's initial value for display. The div stops being a block. A common surprise.
  2. display: revert rolls back to the browser's stylesheet, which says div { display: block }: block.
  3. unset depends on the property: color is inherited, so color: unset acts like inherit (teal); border isn't, so border: unset acts like initial (no border).
  4. color: initial is the spec's initial colour, CanvasText, which is black (rgb(0, 0, 0)) in a light page, not the parent's teal and not the page's text colour.
  5. font-size: revert on the second h2 throws away the author 40px and goes back to the browser's h2 size, 1.5em of the 15px body: 22.5px.
AdvancedResetting everything with all

The all shorthand resets every property at once (except direction, unicode-bidi and custom properties). It's how you strip a <button> to plain text and build it back up. Be careful: all: unset also removes the browser's focus styles and the button's display, so add focus styles back yourself (see Visible focus).

Stripping a button
CSS
.bare     { all: unset; }
.reverted { all: revert; }
.parent   { color: teal; font-size: 18px; }
HTML
<div class="parent">
  <button class="bare" id="bare">all: unset</button>
  <button class="reverted" id="rev">all: revert</button>
  <button id="normal">normal</button>
</div>
Resultlive, in your browser
What’s happening
  1. all: unset sets every property to unset. Non-inherited ones get their initial values: transparent background, display: inline. Inherited ones take the parent's values: 18px and teal.
  2. The result looks like plain text but is still a <button>: the test focuses it and Chrome still gives it the role button, so it's keyboard-activatable and announced as a button. That's the point of the technique.
  3. all: revert rolls every property back to the browser's stylesheet, so the button looks like the normal one: same display and font size.
display: initial on a div
CSS
.box { display: initial; width: 200px; height: 50px; background: gold; }
HTML
<div class="box">Hello</div>

How wide is the gold box?

Show answer

As wide as the word "Hello", not 200px. display: initial is inline, and inline boxes ignore width and height.

The box measured
CSS
.box { display: initial; width: 200px; height: 50px; background: gold; }
HTML
<div class="box" id="b">Hello</div>
Resultlive, in your browser
What’s happening
  1. initial means the property's initial value from the spec, and the initial value of display is inline, whatever the element is.
  2. As an inline box, the div ignores width: 200px and height: 50px (see display) and is only as wide as "Hello".
  3. To get the element's normal display back, use display: revert (block for a div).

#!important

Added

!important after a value moves that declaration into a higher tier of the cascade. The cascade's first step is origin and importance, and important declarations are compared there, before inline styles, layers and specificity. So .title { color: green !important } beats #title { color: crimson } and even a style attribute.

Two rules follow from where it sits in the cascade. Among important declarations, the normal rules still apply: if two !important declarations compete, the more specific wins, then the later one. And the origin order flips for important declarations: a user's !important beats an author's !important, and the browser's !important beats both. That inversion is deliberate: it lets a user's accessibility stylesheet (larger text, high contrast) win over a site's styles. The same flip happens with cascade layers.

Mental model: !important is a priority lane. Everything in the priority lane is served before anything in the normal lane, but inside the priority lane, people still queue by the normal rules. If everything uses the priority lane, it's just a normal queue again, only harder to reason about.

An important class against an id and an inline style
CSS
#title { color: crimson; }
.title { color: green !important; }
HTML
<h3 class="title" id="title" style="color: navy">Important wins</h3>
Resultlive, in your browser
What’s happening
  1. Three declarations set color: the id rule (1-0-0, normal), the inline style (normal), and .title (0-1-0, important).
  2. Round one of the cascade is origin and importance. The important declaration is the only one in the "author important" tier, so it wins outright: green.
  3. Specificity and the inline-style step were never consulted. That's the power of !important, and also why it's hard to undo: the only way to beat it is another !important that wins on the later criteria.
AdvancedImportant against important

Inside the important tier, the cascade carries on as usual: inline styles beat selectors, then specificity, then order.

Important vs important
CSS
.a { color: crimson !important; }
p.a { color: green !important; }
.b { color: crimson !important; }
HTML
<p class="a" id="a">two important rules</p>
<p class="b" id="b" style="color: navy !important">important inline vs important rule</p>
Resultlive, in your browser
What’s happening
  1. .a (0-1-0) and p.a (0-1-1) are both important. They tie on importance, so specificity decides: green.
  2. #b has an important rule and an important inline style. Element-attached styles beat selectors at the same importance: navy.
  3. So an inline !important (often set by JavaScript with el.style.setProperty("color", "navy", "important")) can't be beaten from a stylesheet at all.
Advanced!important is ignored inside keyframes

Declarations inside @keyframes that carry !important are ignored. Animations live in their own origin (below important author styles), and they can't promote themselves into the important tier.

An important keyframe declaration
CSS
@keyframes try { from, to { color: crimson !important; } }
@keyframes plain { from, to { color: teal; } }
.x { color: navy; animation: try 100s paused; }
.y { color: navy; animation: plain 100s paused; }
HTML
<p class="x">keyframe with !important</p>
<p class="y">keyframe without it</p>
Resultlive, in your browser
What’s happening
  1. .x's animation sets color: crimson !important. That declaration is discarded, so the keyframes animate nothing and the normal navy applies.
  2. .y's identical animation without !important works: teal, beating color: navy as animations do.
AdvancedWhen !important is the right tool

!important isn't evil, it's a tool with a specific job: declarations that must win regardless of context.

  • Utility classes that should always do what they say: .hidden { display: none !important; }, .sr-only. Tailwind's ! modifier exists for this.
  • The [hidden] fix: [hidden] { display: none !important; }, so a component's display rule can't make hidden things visible (see Hiding things).
  • Overriding inline styles you don't control, from a third-party widget or a CMS.
  • User stylesheets and accessibility overrides, where the reversed origin order is the whole point.
  • prefers-reduced-motion overrides that must switch off every animation on the page.

The bad use is fighting your own CSS: adding !important because a rule "doesn't work" usually means specificity or order needs fixing, and the next person will need another !important to override yours. Cascade layers are now the cleaner way to make one group of styles reliably beat another.

Overriding a widget's inline style
CSS
.widget-fix .banner { background: #e0f2fe !important; }
.banner { padding: 8px; }
HTML
<div class="widget-fix">
  <div class="banner" id="w" style="background: #fecaca">third-party inline style</div>
</div>
Resultlive, in your browser
What’s happening
  1. The widget writes background: #fecaca into a style attribute, which beats every normal selector.
  2. An !important declaration is in a higher tier than any normal declaration, inline or not: the background is blue (rgb(224, 242, 254)).
  3. Scoping the fix under .widget-fix limits it to that one widget instead of every .banner on the site.
Which important wins?
CSS
#nav a { color: crimson !important; }
.menu a { color: green !important; }
a.link  { color: navy; }
HTML
<nav id="nav" class="menu"><a class="link" href="#">Home</a></nav>
Show answer

Crimson. Both important declarations beat the normal one; between them, #nav a (1-0-1) is more specific than .menu a (0-1-1).

The rules applied
CSS
#nav a  { color: crimson !important; }
.menu a { color: green !important; }
a.link  { color: navy; }
HTML
<nav id="nav" class="menu"><a class="link" href="#">Home</a></nav>
Resultlive, in your browser
What’s happening
  1. Importance first: a.link is normal and is knocked out by both important declarations.
  2. The two important declarations tie on origin, have no inline style or layers, so specificity decides: 1-0-1 beats 0-1-1.
  3. Crimson. Order of appearance would only matter if their specificity were equal.

#Cascade layers with @layer

Added

Cascade layers let you group styles and decide, explicitly, which group wins. You declare the order once (@layer reset, base, components, utilities;) and put rules into layers (@layer components { … }). In the cascade, the layer step comes before specificity: a declaration in a later layer beats one in an earlier layer even if its selector is far less specific. Styles that aren't in any layer form an implicit final layer, so unlayered styles beat all layered styles.

It solves the oldest problem in CSS architecture: third-party CSS, resets and base styles with heavy selectors that your components have to out-specify. With layers, priority comes from where you put a rule, not how hard its selector is. MDN lists @layer as Baseline widely available since March 2022.

Mental model: layers are transparent sheets stacked on a projector. The sheet on top shows through wherever it has ink, no matter how bold the ink on the sheets below is. Specificity only matters between rules on the same sheet.

Layer order beats specificity
CSS
@layer base, components;

@layer base {
  #save.btn.primary { color: crimson; padding: 4px 8px; }
}
@layer components {
  .btn { color: green; }
  .link-btn { color: purple; }
}

/* unlayered */
.link-btn { color: navy; }
HTML
<button class="btn primary" id="save">Save</button>
<button class="btn link-btn" id="more">More</button>
Resultlive, in your browser
What’s happening
  1. @layer base, components; fixes the order: base first (lowest priority), then components. Unlayered styles come after both.
  2. For #save's color, #save.btn.primary (1-2-0, in base) competes with .btn (0-1-0, in components). The layer step runs before specificity, and components is later: green.
  3. The cascade is still per property: components doesn't set padding, so the base rule's 4px padding applies.
  4. For #more, .link-btn in components (purple) loses to the unlayered .link-btn (navy), because unlayered styles sit above every layer.
  5. In practice: put resets and third-party CSS in early layers, your components in later ones, and keep a few unlayered overrides for emergencies.
AdvancedLayer order is fixed by first mention

A layer's position is decided the first time its name appears, whether in an @layer a, b; statement or an @layer a { … } block. Adding more rules to a layer later doesn't move it. That's why you write the order statement at the very top of your CSS.

Re-opening an earlier layer
CSS
@layer theme, overrides;
@layer overrides { p { color: green; } }
@layer theme { p { color: crimson; } }   /* re-opened later in the file */
HTML
<p id="p">Which layer wins?</p>
Resultlive, in your browser
What’s happening
  1. The first line fixes the order: theme, then overrides.
  2. The theme block appears last in the file, but it only adds rules to the existing theme layer, which stays in first (lowest) position.
  3. So overrides wins: green. Without the order statement, the order would be whichever layer was mentioned first in the file.
Advanced!important reverses the layer order

As with origins, the layer order reverses for !important declarations: an important declaration in an earlier layer beats one in a later layer, and important declarations in any layer beat important unlayered ones. That lets a reset layer protect something (say [hidden] { display: none !important }) that later layers can't accidentally undo.

Important declarations across layers
CSS
@layer reset, components;
@layer reset      { .a { color: crimson !important; } .b { color: crimson !important; } }
@layer components { .a { color: green !important; } }
.b { color: navy !important; } /* unlayered */
HTML
<p class="a" id="a">important in two layers</p>
<p class="b" id="b">important in a layer and unlayered</p>
Resultlive, in your browser
What’s happening
  1. .a has important declarations in reset (crimson) and components (green). Normally components would win, but for !important the order flips: reset wins, crimson.
  2. .b has an important declaration in reset and an important unlayered one. Unlayered styles are the strongest normal styles but the weakest important ones: crimson again.
  3. MDN says it directly: important declarations in layers take precedence over important declarations outside a layer.
AdvancedNested and anonymous layers

Layers can be nested: @layer framework { @layer base { … } } creates framework.base, and you can refer to it by that dotted name from anywhere. Nested layers are ordered among themselves inside their parent. A layer with no name (@layer { … }) is anonymous: it gets a position but nothing can add to it later. A whole stylesheet can be imported into a layer with @import url("bootstrap.css") layer(vendor);, which is how you push a framework below your own code without touching it.

Ordering inside a nested layer
CSS
@layer framework {
  @layer base, theme;
  @layer theme { p { color: green; } }
  @layer base  { p { color: crimson; } }
}
@layer framework.base { p { font-weight: 700; } }
@layer app { p { text-decoration: underline; } }
HTML
<p id="p">nested layers</p>
Resultlive, in your browser
What’s happening
  1. Inside framework, base is declared before theme, so framework.theme wins color: green.
  2. @layer framework.base { … } adds a rule to the nested layer by its full name: bold.
  3. app is a top-level layer declared after framework, so the whole framework layer (with both its sub-layers) sits below it. Nothing else sets text-decoration, so its underline applies anyway.
AdvancedRolling back one layer with revert-layer

revert-layer makes a property act as if the declarations in the current layer didn't exist, so the value from the previous layer (or origin) shows through. It's the layer-level version of revert (see Inheritance and the keywords). MDN lists revert-layer as Baseline widely available since March 2022.

Opting one element out of a theme layer
CSS
@layer base, theme;
@layer base  { a { color: green; } }
@layer theme {
  a { color: crimson; }
  a.plain { color: revert-layer; }
}
HTML
<a href="#" id="t">themed link</a> <a href="#" class="plain" id="p">plain link</a>
Resultlive, in your browser
What’s happening
  1. Both links match the theme's a { color: crimson }.
  2. a.plain sets color: revert-layer inside theme, which discards the theme layer's declarations for that property. The cascade falls back to base: green.
  3. Without base, it would fall back to the browser's link colour.
Layered or not?
CSS
@layer components { #hero .title { color: crimson; } }
.title { color: green; }
HTML
<section id="hero"><h1 class="title">Hi</h1></section>

What colour is the heading?

Show answer

Green. The unlayered .title beats every layered declaration, whatever their specificity.

The rules applied
CSS
@layer components { #hero .title { color: crimson; } }
.title { color: green; }
HTML
<section id="hero"><h1 class="title">Hi</h1></section>
Resultlive, in your browser
What’s happening
  1. #hero .title is 1-1-0 but lives in the components layer; .title is 0-1-0 and unlayered.
  2. The layer step comes before specificity, and unlayered styles count as the last layer: green.
  3. This catches teams adopting layers gradually: old unlayered CSS beats everything in the new layers. Wrap legacy CSS in its own layer (for example with @import url(legacy.css) layer(legacy)) to put it back in its place.

#:is(), :where(), :not() and :has()

Added

These four functional pseudo-classes take selector lists as arguments. :is(A, B) matches anything that matches A or B, which shortens repetitive selectors: :is(h1, h2, h3) a instead of three selectors. :where(A, B) matches exactly the same elements but has zero specificity. :not(A, B) matches elements that match none of the arguments. :has(A) matches an element if A matches relative to it, usually something inside it or after it: .card:has(img) is "a card that contains an image". That's the long-wanted parent selector, and also a previous-sibling selector (h2:has(+ p)).

The specificity rules are the interview question. :is(), :not() and :has() take the specificity of their most specific argument, whichever argument actually matched. :where() always counts 0-0-0, which makes it ideal for defaults that anyone should be able to override.

Mental model: :is() and :where() are the same filter with different weights: :is weighs as much as its heaviest argument, :where weighs nothing. :has() turns selectors around: instead of describing the element by its ancestors, you describe it by what it contains.

Four functional pseudo-classes
CSS
:is(h2, h3) a        { color: crimson; }
li:not(.done)        { font-weight: 700; }
.card:has(img)       { border: 2px solid teal; }
:where(.card) p      { color: navy; }
p                    { color: green; }
.card { padding: 8px; margin-bottom: 8px; }
HTML
<h2><a href="#" id="a2">in an h2</a></h2>
<h3><a href="#" id="a3">in an h3</a></h3>
<ul><li class="done" id="l1">done</li><li id="l2">to do</li></ul>
<div class="card" id="c1"><svg width="40" height="20"></svg><p id="cp">card text</p></div>
<div class="card" id="c2"><p>no image here</p></div>
<div class="card" id="c3"><img alt="" width="40" height="20"></div>
Resultlive, in your browser
What’s happening
  1. :is(h2, h3) a matches links inside an h2 or an h3: both crimson. It's the same as writing h2 a, h3 a, minus one important difference covered below (forgiving parsing).
  2. li:not(.done) matches the list item without .done: only "to do" is bold.
  3. .card:has(img) styles a card based on its content: only #c3, which contains an <img>, gets the teal border. #c1 contains an <svg>, not an img, so it doesn't match.
  4. :where(.card) p would be 0-1-1 as .card p, but :where() makes it 0-0-1, the same as p. Equal specificity, so the later p rule wins: green. That's the point of :where(): a default that a plain type selector can override.
Advanced:is() takes its strongest argument's specificity

:is() uses the specificity of its most specific argument for every element it matches, even if the element matched through a weaker argument. An id in the list makes the whole selector id-strength. :where() is the opposite: always zero.

An id inside :is() and :where()
CSS
:is(#nothing, p)  { color: crimson; } /* 1-0-0, though it matches through p */
p.lead            { color: green; }   /* 0-1-1 */
:where(#nothing, .note) { color: crimson; } /* 0-0-0 */
div               { color: navy; }    /* 0-0-1 */
HTML
<p class="lead" id="p">a paragraph</p>
<div class="note" id="d">a div</div>
Resultlive, in your browser
What’s happening
  1. No element has id="nothing", so :is(#nothing, p) matches the paragraph through p. Its specificity is still 1-0-0, from #nothing, so it beats p.lead (0-1-1): crimson.
  2. :where(#nothing, .note) matches the div through .note, with specificity 0-0-0. Even div (0-0-1) beats it: navy.
  3. This is a favourite trick question, and a real bug source when someone puts an id into an :is() list "for convenience".
AdvancedForgiving and unforgiving selector lists

A normal selector list is invalid as a whole if one selector is invalid (see Selectors). :is() and :where() take a forgiving selector list: invalid or unsupported arguments are just ignored. :not() and :has() are not forgiving, so one unknown selector inside them invalidates the whole rule.

An unknown pseudo-class inside each function
CSS
p, p:unknown-thing          { color: crimson; }
:is(p, :unknown-thing).a    { color: green; }
:where(p, :unknown-thing).b { color: green; }
p:not(:unknown-thing, .x).c { color: green; }
div:has(:unknown-thing, i)  { color: green; }
HTML
<p id="plain">plain list</p>
<p class="a" id="a">:is()</p>
<p class="b" id="b">:where()</p>
<p class="c" id="c">:not()</p>
<div id="d"><i>:has()</i></div>
Resultlive, in your browser
What’s happening
  1. The plain list p, p:unknown-thing is dropped entirely, so even the plain paragraph isn't crimson.
  2. :is(p, :unknown-thing) and :where(p, :unknown-thing) ignore the unknown argument and keep working: green.
  3. :not(:unknown-thing, .x) and :has(:unknown-thing, i) aren't forgiving, so both rules are invalid and nothing turns green.
  4. That makes :is() a safe wrapper for progressive enhancement: :is(:focus-visible, .focus-fallback) keeps working in a browser that doesn't know one of the arguments.
AdvancedGotcha: :not() with a descendant
Excluding a subtree
CSS
:not(.dark) p { text-decoration: underline; }
p:not(.dark p) { color: green; }
.dark { background: #1e293b; color: #fff; padding: 6px; }
HTML
<p id="out">outside .dark</p>
<div class="dark"><p id="in">inside .dark</p></div>
Resultlive, in your browser
What’s happening
  1. For #in, :not(.dark) p looks for any ancestor that isn't .dark. body qualifies, so #in is underlined despite being inside .dark.
  2. p:not(.dark p) asks whether the paragraph itself matches .dark p. #in does, so it's excluded; #out doesn't, so it's green.
AdvancedWhat :has() can do

:has() takes a relative selector: by default it looks at descendants, but it can start with a combinator: > img (direct child), + p (the next sibling), ~ p (a later sibling). That covers patterns that needed JavaScript before. MDN lists :has() as Baseline widely available, in all browsers since December 2023. It can't be nested inside another :has(), and pseudo-elements aren't allowed in it.

Four :has() patterns
CSS
.field:has(input:invalid) label { color: crimson; }
h3:has(+ p)                     { margin-bottom: 0; color: teal; }
ul:has(> li:nth-child(4))       { columns: 2; }
form:has(input[type="checkbox"]:checked) .submit { background: #bbf7d0; }
:has(:has(a)) { outline: 5px solid red; } /* invalid: :has() can't nest */
HTML
<div class="field"><label id="lbl">Email</label><input type="email" required></div>
<h3 id="h1">heading followed by a paragraph</h3><p>text</p>
<h3 id="h2">heading followed by a list</h3><ul id="short"><li>a</li><li>b</li></ul>
<ul id="long"><li>1</li><li>2</li><li>3</li><li>4</li></ul>
<form><input type="checkbox" id="ok" checked><button class="submit" id="go">Send</button></form>
Resultlive, in your browser
What’s happening
  1. .field:has(input:invalid) label: the empty required email input is :invalid, so its sibling label inside the same field turns crimson. Styling a container or a label from its input's state is the classic :has() use.
  2. h3:has(+ p) selects an h3 whose next sibling is a p: a previous-sibling selector. Only the first heading is teal.
  3. ul:has(> li:nth-child(4)) is a quantity query: only lists with at least 4 items get columns: 2.
  4. form:has(…:checked) .submit reacts to state anywhere in the form, and updates live: when the test unticks the box, the button's green goes away.
  5. :has(:has(a)) is rejected by the parser, so that rule isn't in the stylesheet at all.
What colour is the link?
CSS
:is(nav, #footer) a { color: crimson; }
.menu a.active      { color: green; }
HTML
<nav class="menu"><a class="active" href="#">Home</a></nav>
Show answer

Crimson. :is(nav, #footer) a is 1-0-1, because :is() takes the specificity of #footer, even though the link matched through nav.

The rules applied
CSS
:is(nav, #footer) a { color: crimson; }
.menu a.active      { color: green; }
HTML
<nav class="menu"><a class="active" href="#" id="a">Home</a></nav>
Resultlive, in your browser
What’s happening
  1. .menu a.active is 0-2-1: two classes and one type.
  2. :is(nav, #footer) a takes its most specific argument, #footer, so it's 1-0-0 plus a: 1-0-1. There's no #footer on the page, but that doesn't matter.
  3. One id beats any number of classes: crimson. Rewriting it as :where(nav, #footer) a (0-0-1) would let .menu a.active win.

#Scoping styles with @scope

Added

@scope limits rules to a subtree of the document: @scope (.card) { img { … } } styles images inside .card and nowhere else. It can also have a lower boundary: @scope (.card) to (.content) { … } stops at .content, leaving a "donut" so a component's styles don't leak into the slot where other components are rendered. Inside the block, :scope refers to the scope root itself.

It adds two things selectors alone couldn't. First, proximity: when two scoped rules conflict, the one whose scope root is fewer steps up the DOM wins, so nested themes finally behave. That's a step in the cascade after specificity and before order of appearance, and the spec counts rules without a scope as infinitely far away, so at equal specificity a scoped rule beats an unscoped one wherever it's written. Second, no specificity cost: the scope root's selector doesn't add to the specificity of the rules inside (a bare img in the block is 0-0-1, unlike .card img which is 0-1-1).

Support: @scope became Baseline newly available when Firefox 146 shipped it in December 2025 (web.dev); MDN's banner dates it "Baseline 2026, newly available since March 2026". Either way it's new: browsers older than that ignore the whole @scope block, so provide a fallback (or a class-based naming convention) for them. Mental model: @scope is a fence around a garden bed: the rules apply only inside the fence, an inner fence (the to boundary) protects a bed within it, and when two gardeners' rules disagree about a plant, the one whose fence is closest gets the say.

A scope with a lower boundary
CSS
@scope (.card) to (.content) {
  :scope { border: 2px solid teal; padding: 8px; }
  p { color: teal; }
}
HTML
<p id="outside">outside any card</p>
<div class="card">
  <p id="inside">in the card</p>
  <div class="content">
    <p id="slot">in the card's content slot</p>
  </div>
</div>
Resultlive, in your browser
What’s happening
  1. @scope (.card) makes .card the scope root. :scope inside the block selects the root itself: the card gets the teal border.
  2. p in the block only matches paragraphs inside the card: #inside is teal, #outside isn't.
  3. to (.content) sets the lower boundary: elements inside .content are outside the scope, so #slot isn't teal, even though it's inside the card.
  4. That "donut" is exactly what component styles need: style your own markup, but not the children someone passes into your slot.
AdvancedProximity: the nearest scope wins

The combinators topic showed .light a and .dark a failing for nested themes: equal specificity, so the later rule wins regardless of nesting. With @scope, the cascade compares scope proximity (DOM hops from the element to its scope root) after specificity, so the nearest theme wins whatever the source order.

The same themes with descendant selectors and with @scope
CSS
.light a { color: navy; }
.dark a  { color: gold; }

@scope (.s-light) { a { color: navy; } }
@scope (.s-dark)  { a { color: gold; } }

.dark, .s-dark { background: #1e293b; padding: 8px; margin-bottom: 8px; }
.light, .s-light { background: #f8fafc; padding: 8px; }
HTML
<div class="dark"><div class="light"><a href="#" id="desc">descendant selectors</a></div></div>
<div class="s-dark"><div class="s-light"><a href="#" id="scoped">@scope</a></div></div>
Resultlive, in your browser
What’s happening
  1. The first link is inside .light, inside .dark. Both descendant rules are 0-1-1, so order decides and the later .dark a makes it gold on a light background.
  2. The second link matches both scoped rules too. Both a selectors are 0-0-1, so specificity ties.
  3. Proximity comes next: .s-light is 1 step up from the link, .s-dark is 2 steps. The nearer root wins: navy, the correct colour for the light panel, even though the .s-dark rule comes later.
AdvancedSpecificity inside @scope

Rules inside @scope keep the specificity of their own selector; the root's selector doesn't count. :scope itself counts as a pseudo-class (0-1-0) if you write it. Specificity is compared before proximity, so a more specific rule from a farther scope (or from no scope) still wins; only when specificity ties does the nearer scope, or any scope over none, decide.

An id as scope root, and specificity against proximity
CSS
@scope (#hero) { p { color: crimson; } }  /* p: 0-0-1 */
.intro { color: green; }                  /* 0-1-0 */

@scope (#hero) { :scope .tag { color: crimson; } } /* :scope .tag: 0-2-0 */
.tag.big { color: green; }                         /* 0-2-0, written later */

@scope (.outer) { .x { font-style: italic; } }   /* 0-1-0, farther */
@scope (.inner) { p { font-style: normal; } }    /* 0-0-1, nearer */
HTML
<section id="hero">
  <p class="intro" id="i">intro</p>
  <span class="tag big" id="t">tag</span>
</section>
<div class="outer"><div class="inner"><p class="x" id="x">specificity vs proximity</p></div></div>
Resultlive, in your browser
What’s happening
  1. @scope (#hero) { p { … } } is only 0-0-1, despite the id in the prelude. .intro (0-1-0) beats it on specificity, before proximity is looked at: green.
  2. :scope .tag counts :scope as a pseudo-class: 0-2-0, the same as .tag.big. With specificity tied, proximity is next, and the unscoped .tag.big counts as infinitely far away, so the scoped rule wins even though .tag.big comes later: crimson.
  3. In the last case, .x from the farther .outer scope (0-1-0) beats p from the nearer .inner scope (0-0-1): specificity is compared before proximity, so the paragraph is italic.
  4. So @scope is a way to limit reach and to break ties sensibly, not a way to win against more specific selectors. (In the first case, writing #hero p instead would be 1-0-1 and beat .intro.)
AdvancedScoping to where the style element sits

Inside a <style> element, @scope { … } without a selector scopes the rules to the <style> element's parent. That gives you component-local styles written right next to the markup, with no class names to invent.

A style element scoped to its parent
CSS
p { color: navy; }
HTML
<div id="widget">
  <style>@scope { p { color: teal; } }</style>
  <p id="w">inside the widget</p>
</div>
<p id="o">outside</p>
<style>p { color: navy; } /* the same unscoped rule again, later in the document */</style>
Resultlive, in your browser
What’s happening
  1. The <style> inside #widget contains @scope { … } with no prelude, so #widget (its parent) is the scope root.
  2. #w is inside: teal. #o is outside: navy from the page's p rule. The HTML repeats that unscoped p rule in a later <style>, and #w is still teal.
  3. Both p rules are 0-0-1. Inside the widget the scoped one wins on proximity (an unscoped rule counts as infinitely far), not because it comes later, and it can't reach anything outside.
AdvancedBefore @scope: how teams scope CSS
Which scope wins?
CSS
@scope (.page)  { .btn { color: crimson; } }
@scope (.modal) { .btn { color: green; } }
HTML
<div class="modal"><div class="page"><button class="btn">OK</button></div></div>
Show answer

Crimson. Specificity ties (0-1-0), and .page is the nearer scope root (1 hop) than .modal (2 hops). Source order never gets a say.

The rules applied
CSS
@scope (.page)  { .btn { color: crimson; } }
@scope (.modal) { .btn { color: green; } }
HTML
<div class="modal"><div class="page"><button class="btn" id="b">OK</button></div></div>
Resultlive, in your browser
What’s happening
  1. Both scoped rules match the button with .btn (0-1-0): specificity ties.
  2. Proximity: .page is the button's parent, 1 hop; .modal is 2 hops. Fewer hops wins.
  3. Crimson, even though the .modal rule comes later. Only if proximity also tied would order of appearance decide.

Written fresh for interviews (there was no Google Doc for this one). Every live example was checked in headless Chrome 155 by scripts/check-demos.mjs.

RohitDownloads · All chapters

Sync progress across devices

Type the same private phrase on your Mac and your phone, and your Learned ticks follow you between them — on every study site.

The phrase never leaves this device: only a fingerprint of it is sent, and the server stores a fingerprint of that. Anyone who knows the phrase could see or change your ticks, so pick something you don’t use elsewhere.

Sync is on in this browser.

Sync ID

This ID must be the same on every device. If another device shows a different one, its phrase is different (capital letters count): tap “Turn off here” on it and type the phrase again exactly.