RizTech Academy logo
RizTech Academy
CSS FoundationsLesson 3 of 830 min

Selectors and specificity

Selectors are how you point at elements. There are a lot of them, you will use about twelve, and the part that actually causes trouble is not the selectors — it is specificity, which decides who wins when two of them disagree.

The ones you will use

h1              { }   /* type: every h1 */
.card           { }   /* class: every element with class="card" */
#main           { }   /* id: the one element with id="main" */
*               { }   /* everything */
[type="email"]  { }   /* attribute */

Use classes for nearly everything. Type selectors are for base styles (body, a, h1); ids are for linking and JavaScript, not styling, for reasons the specificity section makes obvious.

Combinators: the four ways to relate elements

nav a          { }   /* descendant: any a inside nav, at any depth */
nav > a        { }   /* child: only a direct child of nav */
h2 + p         { }   /* next sibling: the p immediately after an h2 */
h2 ~ p         { }   /* subsequent siblings: every p after an h2, same parent */

The descendant/child distinction matters constantly. With a list-based nav from module 1:

<nav><ul><li><a href="/">Home</a></li></ul></nav>

nav > a matches nothing — the a is a grandchild, inside li. nav a matches it. This is one of the most common "why is my CSS not applying" causes, and devtools will show you the rule simply is not there.

Grouping

h1, h2, h3 { line-height: 1.2; }

One rule, three selectors. A single mistake in one of them — h2,,h3 — invalidates that selector only in modern browsers, not the whole rule. Older behaviour discarded the lot, which is why you will see advice to keep them on separate lines.

Pseudo-classes: state

a:hover              { }   /* mouse over — not available on touch */
a:focus              { }
a:focus-visible      { }   /* focused AND the browser thinks a ring is warranted */
a:active             { }   /* while being pressed */
a:visited            { }
input:disabled       { }
input:checked        { }
input:required       { }
input:user-invalid   { }   /* invalid, but only after they have interacted */

Two things here matter more than the rest.

Use :focus-visible, not :focus, for focus rings. :focus fires when you click a button with a mouse too, so a designer sees an "ugly" outline on click and removes it — which destroys keyboard navigation for everybody. :focus-visible fires for keyboard focus and not for a mouse click, so there is no longer any reason to remove the ring:

:focus-visible {
  outline: 2px solid #1a4d2e;
  outline-offset: 2px;
}

Never outline: none without a replacement. It is the single most damaging line in web development. A keyboard user loses all idea of where they are on the page.

:hover does not exist on a touchscreen. A phone has no hover. Anything only reachable by hovering — a dropdown menu, a "reveal on hover" button — is unreachable for most of your visitors. Design for tap; treat hover as decoration.

a:link    { }
a:visited { }
a:hover   { }
a:active  { }

They must be in that order. All four have the same specificity, so source order decides — put :hover before :visited and a visited link never shows its hover state. The mnemonic is LoVe, HAte.

Structural pseudo-classes

li:first-child       { }
li:last-child        { }
li:nth-child(3)      { }
li:nth-child(odd)    { }
li:nth-child(2n + 1) { }
p:only-child         { }
li:not(:last-child)  { }
p:empty              { }

:not() is the one that saves the most code — li:not(:last-child) { border-bottom: 1px solid #ddd } is a divider between items and not after the last one, in one rule.

Watch the difference between :first-child and :first-of-type:

<div>
  <h2>Prices</h2>
  <p>Atta ₹450</p>
</div>

p:first-child matches nothing — the h2 is the first child. p:first-of-type matches the paragraph. This catches everyone once.

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

:is(h1, h2, h3) { margin-block: 0.5em 0.25em; }      /* shorthand */
:where(h1, h2, h3) { margin-block: 0.5em 0.25em; }   /* shorthand, zero specificity */

.card:has(img) { padding: 0; }                       /* a card that contains an img */
label:has(+ input:required)::after { content: " *"; }

:is() takes the specificity of its most specific argument. :where() always counts as zero, which makes it the tool for writing defaults somebody can override with a plain class. That is the difference, and it is the whole reason :where() exists.

:has() is the one CSS lacked for twenty years: a parent selector. It is now supported everywhere current, and it replaces a genuine amount of JavaScript. Module 6 goes further.

Pseudo-elements: generated content

.external::after  { content: " ↗"; }
.required::before { content: "* "; color: #b3261e; }
p::first-line     { font-weight: 600; }
::selection       { background: #cfe8d8; }
input::placeholder { color: #666; }

Two colons by convention, and content is required on ::before/::after — omit it and nothing appears at all, which is the usual reason they "do not work".

The important limit: content generated this way is decoration, not content. Screen reader support for it is inconsistent, it cannot be selected or translated reliably, and it is not in the DOM. Never put information there. An icon, a quotation mark, a decorative arrow — fine. The word "Required", or a price — no, that belongs in the HTML.

Specificity

When two rules set the same property, and origin and importance did not decide it, specificity does. Count three numbers:

(ids, classes, types)
Selector Specificity
* 0,0,0
p 0,0,1
p span 0,0,2
.card 0,1,0
p.card 0,1,1
[type="text"], :hover 0,1,0
.card .title 0,2,0
#main 1,0,0
#main p 1,0,1
inline style="" beats all selectors
!important a different question entirely

Compare left to right, and it is not decimal. 1,0,0 beats 0,15,0 — one id beats fifteen classes. Eleven classes do not "carry" into the id column.

#main p      { color: red; }      /* 1,0,1 — wins */
.a.b.c.d.e p { color: green; }    /* 0,5,1 */

That is why ids are a bad styling tool. An id-based rule can only be overridden by another id or by !important, so a single #sidebar p { color: #666 } becomes something you fight in every component that ever appears in the sidebar.

Three things that do not count:

  • :where() is always 0,0,0, including its arguments.
  • :not() and :is() take the specificity of their argument. :not(.card) is 0,1,0 — the same as .card.
  • The universal selector * adds nothing.

Keeping specificity flat

The aim is that most of your rules sit at 0,1,0 — one class — so anything can override anything by being later.

/* fragile: 0,3,2, tied to a structure you will change */
.page .sidebar ul li a { color: #1a4d2e; }

/* one class, one job */
.sidebar-link { color: #1a4d2e; }

The long version also breaks the moment somebody wraps the list in a div. Long descendant chains are how stylesheets become unmaintainable, and the fix is a class on the thing you mean.

Check your work

What to use for nearly everything. Classes.

Why nav > a often matches nothing. The a is inside an li, so it is a grandchild.

Why :focus-visible rather than :focus. It does not fire on a mouse click, so nobody has a reason to remove the ring.

The most damaging line in web development. outline: none with no replacement.

Why hover is not a design tool. A touchscreen has no hover.

Why LVHA order. All four have equal specificity, so source order decides.

:first-child versus :first-of-type. The first child of any kind, versus the first of that element.

:is() versus :where(). Takes its argument's specificity, versus always zero.

What :has() gives you. A parent selector.

Why nothing appears from ::before. content is missing.

Why generated content must be decoration. It is not in the DOM and screen reader support is inconsistent.

How specificity is compared. Left to right, not decimal — one id beats fifteen classes.

Why ids are bad for styling. Only another id or !important can override them.

What a long descendant chain costs. High specificity, and it breaks when somebody adds a wrapper.

Practice

  1. Build the list-based nav and try to style the link with nav > a. Then nav a.
  2. Write h2 + p and h2 ~ p on a page with three paragraphs after a heading. Compare.
  3. Style :focus and remove the outline. Tab through the page. Then switch to :focus-visible and compare clicking with tabbing.
  4. Build a dropdown that only opens on :hover and try it on a real phone.
  5. Put a:hover before a:visited and visit the link. Explain what you see.
  6. Use p:first-child in a container whose first child is an h2. Then :first-of-type.
  7. Add a divider between list items but not after the last, using :not().
  8. Write the same defaults with :is() and with :where(), then try to override each with a single class.
  9. Use :has() to style a card differently when it contains an image.
  10. Write ::before with no content and confirm nothing renders.
  11. Put the word "Required" in a ::before and listen to the field with a screen reader.
  12. Make #main p { color: red } and .a.b.c.d.e p { color: green } fight. Predict the winner first.
  13. Take a rule of your own with three or more classes in a chain and flatten it to one class. Then add a wrapper div and confirm the flat version still works.
  14. Calculate the specificity of #nav .menu li a:hover by hand, then check it in devtools.

Official documentation

Next: the box model, and the one line of CSS that fixes it.

Stuck on this lesson?

Being stuck is part of it — but being stuck alone for three days is not. Our internship programme pairs this curriculum with code review and one-to-one help from working developers, and it is free.

About the internship