Pseudo-classes vs pseudo-elements: :hover and ::before
CSS pseudo-classes select an element in a state. Pseudo-elements create something never in your HTML, and on an image they silently do nothing.

The number of colons tells you which kind you are looking at:
a:hover { } /* pseudo-CLASS: an existing element, in a state */
p::before { } /* pseudo-ELEMENT: a new box that is not in your HTML */
A pseudo-class selects something that already exists. :hover, :focus, :first-child, :checked, :disabled. The element is in your document; you are targeting it under a condition.
A pseudo-element creates something that does not. ::before, ::after, ::first-line, ::placeholder, ::marker. The browser generates a box for you to style.
Single-colon :before still works, because pseudo-elements used one colon in CSS2 and browsers keep parsing it for compatibility. Write two. The distinction is the only thing telling a reader which kind a selector is.
Pseudo-elements need content
#noContent::before { color: red; }
Measured, the generated content resolved to none, and nothing rendered.
::before and ::after do not exist until you give them a content property. That is the single most common reason a pseudo-element appears to do nothing:
.icon::before {
content: ""; /* required, even when empty */
display: block;
width: 16px;
height: 16px;
background: url(check.svg);
}
content: "" with a size and a background is how most decorative pseudo-elements are built. The empty string is not a placeholder; it is what brings the box into existence.
They do not exist in the DOM
Measured, a paragraph with content: "BEFORE " on its ::before reported a textContent of just "paragraph". The generated text was not there.
That has three practical consequences:
JavaScript cannot select them. There is no querySelector("p::before"). You can read their computed styles:
getComputedStyle(el, "::before").content; // '"BEFORE "'
but you cannot get an element reference, attach a listener, or change their content directly. Changing a class on the real element and letting CSS respond is the way.
Screen readers are inconsistent about them. Some announce generated content, some do not. So ::before is right for decoration and wrong for meaning. An icon that conveys information belongs in the HTML, with a text alternative.
They are not copied. Selecting the text and copying it leaves generated content behind, which is usually what you want for a decorative bullet and surprising if you used one for real text.
The case where it silently does nothing
This one is worth knowing because the diagnosis lies to you.
#img::before { content: "SHOULD NOT SHOW"; }
Measured:
getComputedStyle(img, "::before").content // '"SHOULD NOT SHOW"'
img rendered width // 40 (unchanged)
The computed style reports the content, and nothing renders. The image is exactly as wide as it was.
::before and ::after insert their box inside the element, as its first or last child. A replaced element such as <img>, <input>, <video> or <br> has content that comes from outside the document, and no place to put children. So the pseudo-element is specified, computed, and never rendered. The same happened on <input> in the same run.
This is why ::before on an image never works and why nobody can tell you why from the computed styles. If you need a marker on an image, put it on a wrapper element.
<button> and <select> are the useful exceptions. A button is not a replaced element, so ::before works on it normally.
Specificity: they count differently
For the specificity calculation:
- A pseudo-class counts as a class.
a:hoveris(0,1,1). - A pseudo-element counts as an element.
p::beforeis(0,0,2).
So :hover adds real weight to a selector and ::before adds almost none. That is one reason a link's hover colour can be surprisingly hard to override: a:hover outranks a plain .link class.
Two exceptions worth knowing. :where() contributes zero, whatever is inside it, which makes it the tool for low-specificity defaults. :not(), :is() and :has() take the specificity of their argument, so :not(#id) is as heavy as an id.
The ones worth knowing
Pseudo-classes you will actually use:
| Selector | Matches |
|---|---|
:hover, :focus, :active |
Interaction states |
:focus-visible |
Focused and the browser thinks a ring should show |
:first-child, :last-child |
Position among siblings |
:nth-child(2n) |
Every second sibling |
:not(.x) |
Anything not matching |
:has(> img) |
A parent containing something |
:checked, :disabled |
Form state |
:focus-visible deserves the attention. Removing focus outlines with :focus { outline: none } breaks keyboard navigation for everyone. :focus-visible applies only when the browser judges a visible ring appropriate, which is keyboard focus but not a mouse click, so you can style it without harming accessibility.
:has() is the long-awaited parent selector, and it is now available everywhere. .card:has(img) styles a card differently when it contains an image, which previously needed JavaScript or an extra class.
Pseudo-elements worth knowing:
| Selector | Creates or targets |
|---|---|
::before, ::after |
A generated box inside the element |
::placeholder |
Placeholder text in an input |
::selection |
Highlighted text |
::marker |
A list item's bullet or number |
::first-line, ::first-letter |
Typographic effects |
::backdrop |
The area behind a modal dialog |
::marker is a quiet improvement. Styling list bullets used to mean removing them and rebuilding with ::before; now you can set their colour and content directly.
The nth-child family
The most powerful pseudo-classes, and the ones people avoid because the syntax looks cryptic. It is simpler than it appears.
:nth-child(an + b) matches elements where the position satisfies the formula, counting from 1:
| Selector | Matches |
|---|---|
:nth-child(3) |
The third |
:nth-child(odd) |
1st, 3rd, 5th |
:nth-child(even) |
2nd, 4th, 6th |
:nth-child(3n) |
Every third |
:nth-child(3n + 1) |
1st, 4th, 7th: the start of each group of three |
:nth-child(-n + 3) |
The first three |
:nth-child(n + 4) |
Everything from the fourth on |
The last two are the useful ones nobody knows. -n + 3 counts down, which is how you style "the first N of anything" without a class.
There is a trap in the name. :nth-child counts all siblings, not just matching ones. p:nth-child(2) means "a <p> that is also the second child", so a heading before it shifts the count. :nth-of-type(2) counts only paragraphs and is usually what people meant.
:has() combines with these to do things that used to need JavaScript:
.grid:has(> :nth-child(4)) { grid-template-columns: repeat(2, 1fr); }
That switches the layout only when there are at least four children.
The patterns worth stealing
Four things ::before and ::after do well.
A decorative icon, kept out of the accessibility tree because it carries no meaning:
.external::after { content: " ↗"; }
A required-field marker driven by the real state rather than a class:
label:has(+ input:required)::after { content: " *"; color: crimson; }
A tooltip from a data attribute, with no extra markup:
[data-tip]::after {
content: attr(data-tip);
position: absolute;
opacity: 0;
}
[data-tip]:hover::after { opacity: 1; }
Numbered sections with counters, which survive reordering because the browser does the counting:
body { counter-reset: section; }
h2::before { counter-increment: section; content: counter(section) ". "; }
The common thread is that each one keeps presentation out of the HTML. The counter example in particular is impossible to get wrong when sections are added or moved, which a hand-typed number is not.
What content accepts
More than a string:
content: ""; /* empty box */
content: "→ "; /* literal text */
content: attr(data-label); /* read an attribute */
content: counter(item) ". "; /* a generated number */
content: url(icon.svg); /* an image */
content: "" / ""; /* text plus an accessibility alternative */
attr() is the useful one, letting a pseudo-element display data from the HTML:
<span class="tag" data-count="3">Drafts</span>
.tag::after { content: " (" attr(data-count) ")"; }
The last form, with a slash, supplies an alternative text for assistive technology, which is the specified way to make generated content accessible where it carries meaning.
Form state, without any JavaScript
Pseudo-classes expose most of a form's state to CSS, which means a lot of validation feedback needs no script at all.
| Selector | Matches an input that is |
|---|---|
:required / :optional |
Marked required, or not |
:valid / :invalid |
Passing or failing its own constraints |
:user-valid / :user-invalid |
The same, but only after the user has interacted |
:checked |
A ticked checkbox or radio, or a selected option |
:disabled / :enabled |
Disabled, or not |
:placeholder-shown |
Currently showing its placeholder |
:read-only |
Not editable |
:user-invalid is the important one and is newer than most tutorials. Styling :invalid alone turns every required field red before the user has typed anything, because an empty required field is invalid from the moment the page loads. :user-invalid waits until they have engaged with the field and moved on, which is what everyone actually meant.
input:user-invalid { border-color: crimson; }
input:user-valid { border-color: green; }
:placeholder-shown enables the floating-label pattern with no JavaScript, by detecting whether a field is empty:
input:not(:placeholder-shown) + label { transform: translateY(-1.2em) scale(0.8); }
The ones arriving now
Three additions worth knowing about, because they replace long-standing workarounds.
:has(), the parent selector, is available across browsers. It selects based on what an element contains:
.card:has(img) { } /* cards containing an image */
label:has(+ :required) { } /* a label before a required input */
form:has(:user-invalid) { } /* a form with any failing field */
That last one lets a form style itself based on its contents, which previously required a script watching every field.
:focus-within matches an element containing the focused element, which is how you highlight a whole form group when any input inside it is focused.
::details-content and the scroll-state queries are still landing, and both remove another category of JavaScript. The direction is consistent: state that used to require a listener and a class is becoming expressible in CSS directly.
Three common mistakes
Forgetting content. No content property means no box at all, whatever else you style. content: "" is required even when it is empty.
Using ::before on an image or input. Replaced elements cannot host generated children, the computed style reports your content anyway, and nothing renders. Put it on a wrapper.
Using one colon on a pseudo-element. It works, and it removes the only signal telling a reader whether a selector targets a state or creates a box.
Ordering the interaction states
When several state selectors apply to the same element, source order decides the winner among equals, and getting the order wrong produces states that never appear.
For links, the traditional order is :link, :visited, :hover, :focus, :active, remembered as LVHA. Put :hover before :visited and a visited link stops showing a hover colour, because both have the same specificity and the later one wins.
In modern interfaces the pairing that matters most is :hover and :focus-visible:
.button:hover { background: var(--brand-dark); }
.button:focus-visible { outline: 2px solid var(--brand); outline-offset: 2px; }
Those style different properties, so their order does not matter. The trap is styling the same property in both, where whichever comes last wins for a keyboard user who is also hovering.
A related rule worth stating plainly: never remove a focus style without replacing it. outline: none on its own makes an interface unusable by keyboard, and :focus-visible exists precisely so you can restyle the ring rather than delete it.
Quick reference
a:hover { } /* existing element, in a state */
input:checked { }
li:first-child { }
.card:has(img) { }
:focus-visible { } /* keyboard focus only */
p::before { content: ""; } /* generated box, content required */
::placeholder { }
::selection { }
li::marker { }
/* specificity: pseudo-class = a class, pseudo-element = an element */
Want to generate boxes and watch where they land? Start with the CSS track, or read how to center a div for selectors doing layout work.
More from the blog

Flexbox vs Grid: how to choose in ten seconds
Flexbox vs grid: flex sizes items to their content, grid sizes the tracks. Measured, that gave 32px and 229px against three equal 193px columns.
Read more
CSS specificity: why your style is not applying
Specificity is three numbers compared left to right, not one score. Here are the contests that decide it, measured in a browser.
Read moreReady to write some code?
Put this into practice - start your first free lesson. No setup, no credit card.