Pseudo-Classes & Pseudo-Elements
Select elements based on state, position, and structure — then style parts of elements that don't exist in the DOM. Pseudo-classes and pseudo-elements unlock powerful styling without extra markup.
User-Action Pseudo-Classes
These pseudo-classes respond to how the user interacts with elements. They are the foundation of interactive CSS — enabling hover effects, active states, focus rings, and visited link styling.
:hover
Applies when the user's pointer is over the element. The most commonly used interactive pseudo-class.
a:hover { color: coral; text-decoration: underline; } .card:hover { transform: translateY(-4px); box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15); }
:active
Applies while an element is being activated — typically while a mouse button is pressed down on it.
.btn:active { transform: scale(0.95); box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.2); }
:link & :visited
:link selects unvisited links. :visited selects links that have been visited. Together they let you style links based on browsing history.
a:link { color: #6c63ff; } a:visited { color: #9b59b6; } /* LVHA order: :link, :visited, :hover, :active */
Always declare link pseudo-classes in L-V-H-A order: :link, :visited, :hover, :active. Because of CSS specificity rules, changing this order can cause unexpected overrides. A helpful mnemonic: "Love Hate" (LoVe HAte).
:focus
Applies when an element has received focus — by clicking, tapping, or tabbing to it. Essential for keyboard accessibility.
input:focus { outline: 2px solid #6c63ff; outline-offset: 2px; border-color: #6c63ff; }
:focus-visible
Like :focus, but only applies when focus should be visually indicated — typically for keyboard navigation, not mouse clicks. This is the modern best practice for focus rings.
/* Remove default focus ring for mouse users */ .btn:focus { outline: none; } /* Show focus ring only for keyboard users */ .btn:focus-visible { outline: 2px solid #6c63ff; outline-offset: 3px; }
Use :focus-visible instead of :focus for focus ring styling. This way, mouse users won't see the focus ring, but keyboard users will still get clear visual feedback. It's the best of both worlds for accessibility and aesthetics.
Click a button — no outline. Now use Tab — you'll see the green focus ring appear.
Structural Pseudo-Classes
Structural pseudo-classes select elements based on their position in the DOM tree — their relationship to parents, siblings, and the document itself.
:first-child & :last-child
Select the first or last child element within a parent container.
li:first-child { font-weight: bold; color: #6c63ff; } li:last-child { border-bottom: none; }
- First Item (styled)
- Second Item
- Third Item
- Fourth Item
- Last Item (styled)
:only-child
Matches an element that is the sole child of its parent — no siblings at all.
p:only-child { font-size: 1.25rem; text-align: center; }
The :nth-child() an+b Formula
:nth-child() is one of the most powerful structural selectors. It accepts a formula of the form an+b where a is the cycle size and b is the offset.
/* Every odd element */ tr:nth-child(odd) { background: rgba(108, 99, 255, 0.08); } /* Every even element */ tr:nth-child(even) { background: rgba(108, 99, 255, 0.04); } /* Every 3rd element */ li:nth-child(3n) { color: #00c9a7; } /* Every 3rd, starting at 1st (1, 4, 7, 10...) */ li:nth-child(3n+1) { color: #6c63ff; } /* First 3 items only */ li:nth-child(-n+3) { font-weight: bold; }
Understanding the Formula
| Formula | Matches | Example Items |
|---|---|---|
2n or even | Every even child | 2, 4, 6, 8... |
2n+1 or odd | Every odd child | 1, 3, 5, 7... |
3n | Every 3rd | 3, 6, 9, 12... |
3n+1 | Every 3rd from 1st | 1, 4, 7, 10... |
n+4 | 4th and onward | 4, 5, 6, 7... |
-n+3 | First 3 only | 1, 2, 3 |
5 | Only the 5th | 5 |
:nth-child(3n+1) — highlighted in purple (items 1, 4, 7, 10):
:nth-child(even) — highlighted in teal (items 2, 4, 6, 8, 10):
:nth-child(-n+3) — first 3 items only:
:nth-of-type()
Like :nth-child() but only counts elements of the same type. Very useful when you have mixed element types inside a container.
/* Every odd paragraph, ignoring headings/divs */ p:nth-of-type(odd) { background: rgba(108, 99, 255, 0.1); } /* First heading of its type */ h3:first-of-type { margin-top: 0; }
:nth-child(2) — 2nd child regardless of type
h2 (1st child)
p (2nd child - MATCH)
p (3rd child)
p:nth-of-type(2) — 2nd <p> element
h2 (skipped, not a p)
p (1st of type)
p (2nd of type - MATCH)
:nth-last-child() & :nth-last-of-type()
Same as their counterparts but count from the end instead of the beginning.
/* Last 2 items */ li:nth-last-child(-n+2) { opacity: 0.6; } /* Second to last paragraph */ p:nth-last-of-type(2) { margin-bottom: 2rem; }
Functional Pseudo-Classes: :not(), :is(), :where()
:not() — Negation
Excludes elements that match the given selector. Extremely useful for applying styles to "everything except..."
/* All inputs except submit buttons */ input:not([type="submit"]) { border: 1px solid #ccc; padding: 0.5rem; } /* All list items except the last */ li:not(:last-child) { border-bottom: 1px solid #eee; } /* Modern :not() accepts multiple selectors */ a:not(.nav-link, .logo) { text-decoration: underline; }
- Apple
- Banana
- Cherry
- Date (no bottom border)
:is() — Matches-Any
Groups selectors together. It takes the highest specificity of its arguments. Dramatically reduces repetition in complex selectors.
/* Without :is() — repetitive */ header a:hover, nav a:hover, footer a:hover { color: #6c63ff; } /* With :is() — clean and concise */ :is(header, nav, footer) a:hover { color: #6c63ff; } /* Nested grouping */ :is(h1, h2, h3):is(:hover, :focus) { color: #00c9a7; }
:where() — Zero-Specificity Grouping
Identical to :is() in function, but its specificity is always zero. Perfect for defaults that are easy to override.
/* These styles are easy to override because :where() = 0 specificity */ :where(header, footer) a { color: inherit; text-decoration: none; } /* This will easily override the above */ a { color: #6c63ff; /* wins, because a > :where(...) a */ }
:is(.card, #hero) p has the specificity of #hero p (1-0-1) because :is() takes the highest specificity argument. :where(.card, #hero) p has specificity of just p (0-0-1) because :where() always contributes zero. Use :is() for normal styling, :where() for easily-overridable defaults and utility layers.
The :has() Selector — The "Parent Selector"
:has() is the long-awaited "parent selector" of CSS. It selects an element based on what it contains. This was previously impossible in CSS.
/* Card that contains an image */ .card:has(img) { padding-top: 0; } /* Form with invalid inputs — highlight border */ form:has(input:invalid) { border-color: #ff6b6b; } /* Figure that has a figcaption */ figure:has(figcaption) { border: 1px solid #ddd; padding: 1rem; } /* Previous-sibling selector (h2 followed by p) */ h2:has(+ p) { margin-bottom: 0.5rem; } /* Highlight label when its input is focused */ label:has(+ input:focus) { color: #6c63ff; }
Card with image (purple border via :has)
Card without image (default border)
:has() can replace many JavaScript patterns. Need to style a parent based on child state? Need to select a previous sibling? Need conditional layouts? :has() does it all purely in CSS. It's supported in all modern browsers as of late 2023.
Form Pseudo-Classes
These pseudo-classes target form elements based on their current state — checked, disabled, valid, invalid, required, and more.
/* Checked checkbox/radio */ input:checked { accent-color: #6c63ff; } /* Disabled inputs */ input:disabled { opacity: 0.5; cursor: not-allowed; } /* Valid input (passes constraints) */ input:valid { border-color: #00c9a7; } /* Invalid input (fails constraints) */ input:invalid { border-color: #ff6b6b; } /* Required field */ input:required { border-left: 3px solid #6c63ff; } /* Placeholder currently shown */ input:placeholder-shown { border-style: dashed; }
Pseudo-Elements
Pseudo-elements let you style specific parts of an element, or insert content that doesn't exist in the HTML. They use double colons (::) to distinguish them from pseudo-classes (:).
The CSS3 spec uses :: for pseudo-elements and : for pseudo-classes. Older pseudo-elements like ::before, ::after, ::first-line, and ::first-letter also accept single-colon syntax for backward compatibility, but always prefer the double-colon form in modern code.
::before & ::after
These insert a pseudo-element as the first child (::before) or last child (::after) of the selected element. The content property is required — without it, the pseudo-element won't render.
/* Decorative quote marks */ blockquote::before { content: "\201C"; /* left double quote */ font-size: 3rem; color: #6c63ff; line-height: 0; vertical-align: -0.4em; } /* Required field asterisk */ .required::after { content: " *"; color: #ff6b6b; } /* External link icon */ a[href^="http"]::after { content: " \2197"; /* north-east arrow */ font-size: 0.8em; } /* Decorative underline */ .fancy-title::after { content: ""; display: block; width: 60px; height: 3px; background: #6c63ff; margin-top: 0.5rem; }
Both ::before and ::after require the content property. Even if you want an empty pseudo-element for purely decorative purposes (a shape, a line, a background), you must set content: "". Without it, the pseudo-element simply won't render.
Decorative quotes with ::before and ::after:
CSS pseudo-elements are like invisible helpers in your markup.
Gradient underline with ::after:
Required asterisk with ::after:
Email AddressTooltip with ::after + attr():
Hover over me for a tooltipThe content Property Values
/* String text */ content: "Hello"; /* HTML attribute value */ content: attr(data-label); /* Unicode character */ content: "\2605"; /* star */ /* Image */ content: url("icon.svg"); /* Counter */ content: counter(section); /* Combined */ content: "Chapter " counter(chapter) ": "; /* Empty (for decorative pseudo-elements) */ content: "";
Text Pseudo-Elements
::first-letter
Selects the first letter of the first line of a block-level element. Commonly used for drop caps in magazine or editorial layouts.
.article::first-letter { font-size: 3.5em; font-weight: bold; float: left; line-height: 0.8; margin-right: 0.1em; color: #6c63ff; }
Once upon a time, in a world of cascading style sheets, a developer discovered the power of pseudo-elements. They could now style parts of the page that didn't even exist in the HTML source code, creating beautiful drop caps and decorative flourishes.
::first-line
Selects the first rendered line of a block element. The selection adjusts dynamically as the viewport resizes.
p::first-line { font-weight: 600; font-variant: small-caps; color: #6c63ff; }
The first line of this paragraph is automatically styled differently. Try resizing your browser window and watch how the styled text reflows — the ::first-line pseudo-element always applies to whatever text is currently on the first rendered line.
Other Pseudo-Elements
::placeholder
Styles the placeholder text inside input fields and textareas.
input::placeholder { color: #888; font-style: italic; opacity: 0.7; }
::selection
Styles the portion of text that the user has selected/highlighted with their cursor.
::selection { background: #6c63ff; color: #fff; } .highlight::selection { background: #00c9a7; color: #000; }
Select this text for a purple highlight.
Select this text for a teal highlight.
Select this text for a red highlight.
::marker
Styles the marker of list items — the bullet point or number. Supported properties include color, font-size, content, and several others.
li::marker { color: #6c63ff; font-weight: bold; font-size: 1.2em; } ol li::marker { color: #00c9a7; }
- Purple bullets
- Custom color
- Bold and larger
- Teal numbers
- Bold weight
- No extra markup
Common Gotchas
Pseudo-classes use a single colon (:hover, :nth-child()). Pseudo-elements use a double colon (::before, ::after). While browsers accept :before for backward compatibility, always use ::before in new code to make the distinction clear.
The most common mistake: writing styles for ::before or ::after but forgetting to set content. Even for purely decorative elements (shapes, lines), you need content: "" (empty string). Without it, the pseudo-element doesn't exist in the render tree.
Elements like <img>, <input>, <br>, and <hr> cannot have ::before or ::after pseudo-elements because they are replaced elements — their content is replaced by external resources and they don't have internal content trees.
:nth-child(2) selects the 2nd child of its parent regardless of element type. p:nth-of-type(2) selects the 2nd <p> element. These can behave very differently when a container has mixed element types. When in doubt, use :nth-of-type() — it's usually what you actually want.
:is(.foo, #bar) takes the specificity of its most specific argument (#bar = 1-0-0). This can cause unexpected specificity bumps. If you need zero specificity, use :where() instead.
Pro Tips
Hide the default checkbox with appearance: none, then use ::before on a label or wrapper to create a fully custom checkbox design. You can combine this with :checked to toggle the visual state.
/* Custom checkbox pattern */ input[type="checkbox"] { appearance: none; width: 20px; height: 20px; border: 2px solid #555; border-radius: 4px; position: relative; cursor: pointer; } input[type="checkbox"]::before { content: "\2714"; position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; color: #fff; font-size: 14px; opacity: 0; transition: opacity 0.15s; } input[type="checkbox"]:checked { background: #6c63ff; border-color: #6c63ff; } input[type="checkbox"]:checked::before { opacity: 1; }
Whenever you find yourself writing the same styles for multiple selectors, reach for :is(). It can eliminate dozens of lines of repetitive CSS. For example, :is(h1, h2, h3, h4) { margin-top: 1.5em; } replaces four separate rules.
Use :has(+ ...) as a "previous sibling" selector. For example, h2:has(+ p) selects any <h2> that is immediately followed by a <p>. This is a pattern that was impossible before :has().
Use CSS counters with ::before to automatically number sections, figures, or steps without hardcoding numbers. Combined with counter-reset and counter-increment, you get self-updating numbering.
/* Auto-numbered sections */ .article { counter-reset: section; } .article h2::before { counter-increment: section; content: counter(section) ". "; color: #6c63ff; font-weight: 700; }
While :has() is incredibly powerful, use it judiciously on very large DOM trees. Browser engines must check child elements to evaluate the parent, which is the reverse of normal selector matching. For most real-world use cases this is perfectly fast, but avoid deeply nested :has() within :has() chains.