The Five Selectors You Will Use Every Day
Choosing the right selector is most of the skill in CSS. Properties are easy to look up; deciding which elements a rule should reach, without accidentally catching half the page, is the part that takes judgement. Five selectors cover the overwhelming majority of real stylesheets.
The type selector is just a tag name — p, h1, ul — and it hits every element of that kind on the page. Use it for site-wide defaults, not for individual components. The class selector starts with a dot and matches any element carrying that class attribute. This is the workhorse: classes are reusable, you can put several on one element, and they carry a sensible amount of weight in the cascade. Nearly every rule you write should be a class.
The id selector starts with # and matches the single element with that id. Ids must be unique in a document, which is exactly why they are a poor styling tool — a rule that can only ever apply once is a rule you cannot reuse, and an id is so heavy in the cascade that overriding it later becomes painful. Keep ids for JavaScript hooks and for linking with href="#section", and style with classes. The universal selector * matches everything and is mostly used once, for a reset. The attribute selector matches on an attribute's presence or value, which is how you style all external links or every disabled input without adding a class to each one.
/* Type — site-wide defaults */
p { line-height: 1.6; }
ul { padding-left: 20px; }
/* Class — the one you should reach for by default */
.card { border: 1px solid #e3e3ea; }
.card--wide { max-width: 720px; }
/* Id — unique, heavy, better left for JS and anchors */
#main-nav { position: sticky; top: 0; }
/* Universal — usually only for a reset */
*, *::before, *::after { box-sizing: border-box; }
/* Attribute — style by data instead of by class */
input[type="email"] { text-transform: lowercase; }
a[href^="https://"] { color: #0a58ca; } /* value starts with */
a[href$=".pdf"]::after { content: " (PDF)"; } /* value ends with */
button[disabled] { opacity: 0.5; cursor: not-allowed; } p— type selector, every element with that tag name.price— class selector, every element whose class list containsprice#checkout— id selector, the one element with that id*— universal selector, every element[type="email"]— attribute selector;^=starts with,$=ends with,*=contains
Combinators: Selecting by Relationship
Combinators let you describe where an element sits rather than what it is called. There are four, and the difference between the first two is responsible for a lot of accidental styling.
A space is the descendant combinator: .card p means "every paragraph anywhere inside a card, at any depth". A > is the child combinator: .card > p means "paragraphs that are direct children of the card and nothing deeper". If your card contains a footer with its own paragraphs, the first version styles those too and the second leaves them alone. When a rule is leaking into places you did not expect, the descendant space is usually why.
The remaining two work sideways. + is the next-sibling combinator and matches the element immediately after another one at the same level, which is how you add spacing only between stacked items rather than before the first. ~ is the subsequent-sibling combinator and matches every later sibling, not just the next. Neither can look backwards or upwards: CSS selectors have no way to say "the paragraph before this one" through combinators alone.
/* Descendant (space) — any depth inside */
.card p { margin-bottom: 12px; }
/* Child (>) — one level down only */
.card > p { margin-bottom: 12px; }
/* Next sibling (+) — gaps between items, not before the first */
.list-item + .list-item { border-top: 1px solid #eee; }
/* Subsequent siblings (~) — every later sibling */
h2 ~ p { color: #555555; }
/* Combinators can be chained */
.nav > ul > li + li { margin-left: 16px; } - The
.list-item + .list-itempattern is worth memorising. It puts a divider or a top margin between every pair of items while leaving the first item untouched, which saves you from styling everything and then writing a:first-childrule to undo it.
Specificity: How the Browser Breaks a Tie
When two rules set the same property on the same element, the browser needs a way to choose. It scores each selector as three numbers, usually written as a triple. The first number counts id selectors. The second counts classes, attribute selectors and pseudo-classes such as :hover. The third counts element types and pseudo-elements such as ::before. The universal selector and the combinators score nothing at all.
The comparison is column by column, left to right, and it is not arithmetic in base ten. A selector with one id beats a selector with eleven classes, because the first column is compared before the second is even looked at. #sidebar p scores 1-0-1 and .sidebar .widget .title .link p scores 0-4-1, and the id wins outright. Only when all three numbers tie does source order decide, and the later rule takes it.
This is why an id-based stylesheet becomes miserable to maintain. Every override has to out-specify what came before, so selectors grow longer and longer, and eventually somebody reaches for !important. That declaration jumps above the entire normal cascade, which fixes the symptom and makes the next problem worse — the only way to beat an !important is another !important, and now you are competing with yourself. Treat every !important you feel tempted to write as a signal that a selector further up the file is too specific, and fix that instead. The honest exceptions are narrow: overriding a third-party widget whose CSS you cannot edit, and single-purpose utility classes like .hidden that are meant to win unconditionally.
/* Scored as (ids) - (classes, attributes, pseudo-classes) - (elements) */
p /* 0-0-1 */
.price /* 0-1-0 */
p.price /* 0-1-1 */
.card .price /* 0-2-0 */
.card p.price:hover /* 0-3-1 */
#checkout /* 1-0-0 */
#checkout .card p /* 1-1-1 */
/* One id beats any number of classes */
#sidebar p { color: red; } /* 1-0-1 — wins */
.sidebar .widget .title .link p { color: blue; } /* 0-4-1 */
/* Equal scores: the later rule wins */
.btn { background: #d1039e; }
.btn { background: #7a0060; } /* this one applies */ - Inline
style="..."outranks every selector in your stylesheet !importantoutranks even that, and should be a last resort- Ids beat classes; classes beat element types; combinators and
*add nothing - Identical specificity is settled by source order — later wins
- Keeping almost every selector at one or two classes keeps overrides predictable
Grouping, Excluding, and Keeping Specificity Low
Three modern selectors exist mainly to keep long selector lists readable. :is() takes a list and matches any of them, so :is(h1, h2, h3) .badge replaces three separate selectors. :not() matches elements that do not match what is inside it, which is how you style every card except the featured one without adding a class to the rest.
:where() behaves exactly like :is() with one crucial difference: it always contributes zero to specificity. That makes it the right tool for base styles you fully expect to override later. A rule written as :where(.prose) p scores 0-0-1 rather than 0-1-1, so a single class on the paragraph is enough to change it, and you never end up fighting your own defaults.
One warning about grouping that follows from the previous lesson: if any selector inside a comma-separated list is invalid, the browser discards the whole rule. :is() and :where() are forgiving instead — an unrecognised selector inside them is skipped and the rest still works. That difference matters when you are using a very new selector that older browsers may not know.
/* :is() — one rule instead of three */
:is(h1, h2, h3) .badge { font-size: 12px; vertical-align: middle; }
/* :not() — everything except */
.card:not(.card--featured) { background: #ffffff; }
li:not(:last-child) { margin-bottom: 8px; }
/* :where() — same matching, zero specificity */
:where(.prose) p { margin-bottom: 1em; } /* scores 0-0-1 */
.prose .lead { margin-bottom: 2em; } /* easily overrides it */
/* Careful: a bad selector in a plain list kills the whole rule,
but inside :is() / :where() it is simply skipped. */ :not()adds nothing to specificity by itself, but whatever you put inside it counts.div:not(.wide)scores 0-1-1, exactly as if you had writtendiv.wide. It is a way to exclude elements, not a way to hide weight.
Seeing the Cascade Decide
Reading about specificity is much less convincing than watching it happen. The demo below styles the same list of prices four different ways, and the colours you actually see are the outcome of the tie-breaking rules above rather than the order you might expect.
Try three experiments once it is open. First, move the .price rule below the #total rule — the total stays the same colour, because ids are compared before source order. Second, change #total to .total and watch the later rule take over. Third, add !important to a rule that is currently losing and see it jump to the front, then ask yourself whether that is really the fix you want to leave in the file.
As a rule of thumb for your own projects: aim to keep almost every selector at one class, occasionally two. If you find yourself writing a fourth level to make something apply, the problem is usually a rule elsewhere that was too specific, and shortening that rule is a better fix than lengthening this one.
HTML
<ul class="cart">
<li class="row"><span>Notebook</span> <span class="price">60</span></li>
<li class="row"><span>Pen set</span> <span class="price">145</span></li>
<li class="row"><span>Backpack</span> <span class="price">1299</span></li>
<li class="row row--total"><span>Total</span> <span class="price" id="total">1504</span></li>
</ul> CSS
body {
font-family: system-ui, Arial, sans-serif;
padding: 24px;
}
.cart {
list-style: none;
margin: 0;
padding: 0;
max-width: 320px;
}
/* 0-1-0 */
.row {
display: flex;
justify-content: space-between;
padding: 10px 0;
}
/* 0-1-0 + 0-1-0 = 0-2-0 : only between rows */
.row + .row {
border-top: 1px solid #e3e3ea;
}
/* 0-1-0 */
.price {
color: #333333;
}
/* 0-2-0 beats .price */
.cart .price {
font-weight: 600;
}
/* 1-0-0 beats everything above, wherever it sits in the file */
#total {
color: #d1039e;
font-size: 20px;
}
/* :not() — every row except the total */
.row:not(.row--total) span {
color: #555555;
} - Developer tools list every matching rule for an element in cascade order, with the losers struck through. When a colour is not what you expected, that list answers the question in about two seconds and saves you from guessing at selectors.
