PLAINFINCH learn by building

States and :has()

You will be able to style elements by their state, such as hover, focus or checked, and style a parent by what it contains.

What is a pseudo-class?

A pseudo-class is a selector that matches an element by its state or position instead of its name or class. :hover matches while the pointer is over an element, :checked matches a ticked checkbox, :first-child matches an element that is first among its siblings. You write it straight after another selector with no space: .task:first-child means a .task that is the first child, while .task :first-child with a space means any first child inside a .task.

A pseudo-class adds the specificity of a class, so button:hover beats plain button. The exception is :where(), which adds nothing, and :is(), :not() and :has(), which take the specificity of their most specific argument.

FigureA list item containing a checked checkbox: the selector .task:has(input:checked) matches the list item, the parent, because of what is inside itA list item containing a checked checkbox: the selector .task:has(input:checked) matches the list item, the parent, because of what is inside it
:has() styles an element based on what it contains. The list item is matched because a checked input sits inside it; the selector reads from the parent down.

How do hover, focus and active differ?

:hover matches while a mouse or trackpad pointer is over the element. Touch screens have no real hover, so never hide anything important behind it. :active matches while the element is being pressed, which is useful for a brief pressed look on buttons. :focus matches whenever the element has keyboard focus, whether that came from Tab, a click or a script.

:focus-visible matches only when the browser decides a focus ring should show: after keyboard navigation, and in text fields after any focus. It does not match after a mouse click on a button. That makes it the right place for your focus ring: keyboard users always see where they are, and mouse users are not left with a ring around the button they clicked. It has been supported in all current browsers since 2022.

Never remove the outline without replacing it. A rule like :focus { outline: none; } leaves keyboard users unable to see where they are. Write the order as hover, then focus-visible, then active, so the pressed state wins while pressing.

.btn:hover {
  background-color: #2f4a3a;
}

.btn:focus-visible {
  outline: 3px solid #222;
  outline-offset: 2px;
}

.btn:active {
  transform: translateY(1px);
}

Which form states can you style?

Form controls expose their state as pseudo-classes, and these exist in the static page, so they show without any interaction.

To test hover and focus styles yourself, open DevTools, select the element, and use the :hov button in the Styles panel to force a state on.

input:user-invalid {
  border-color: #b5442c;
}

.send:disabled {
  opacity: 0.6;
  cursor: not-allowed;
}
  • :checked matches ticked checkboxes, selected radio buttons and selected options.
  • :disabled and :enabled match controls with or without the disabled attribute. Style disabled buttons so they clearly look unavailable, and keep the text contrast readable.
  • :required and :optional follow the required attribute.
  • :invalid matches any field that fails its rules, such as an empty required field. It matches as soon as the page loads, so a form full of red borders greets people before they type anything.
  • :user-invalid matches only after the person has interacted with the field and left it invalid. It is supported in all current browsers since 2023 and is the better choice for error styling.
  • :placeholder-shown matches an input while its placeholder is visible, which is handy for floating labels.

How do you pick items by position?

:first-child and :last-child match an element that is the first or last child of its parent. A common use is removing the border under the last row of a list, or the top margin on the first paragraph of a card. :only-child matches an element with no siblings.

:nth-child() takes a formula. odd and even stripe rows. 2n+1 is the same as odd: n counts 0, 1, 2 and so on, giving 1, 3, 5. 3n picks every third item. -n+3 picks the first three. :nth-last-child() counts from the end.

These count all element siblings, whatever their class. .task:nth-child(2) means an element that is the second child and also has the task class, so a heading inside the list shifts the count. :nth-of-type() counts only siblings with the same tag name. Newer browsers also accept :nth-child(2 of .task) to count only matching siblings; it is supported in all current browsers since 2023.

.task:nth-child(even) {
  background-color: #f4f1ea;
}

.task:last-child {
  border-bottom: 0;
}

When do you use :not(), :is() and :where()?

:not() excludes matches. .task:not(:last-child) gives every task except the last a bottom border, so you do not need to undo it afterwards. It accepts a list, as in :not(.done, .hidden).

:is() groups selectors to save repetition. :is(h2, h3) a is the same as h2 a, h3 a. One difference matters: in an ordinary list, one invalid selector throws out the whole rule, while :is() ignores the invalid part and keeps the rest.

:where() works like :is() with zero specificity. That makes it ideal for defaults that should be easy to override: :where(.prose) a { color: navy; } loses to any later rule that targets the links, even a plain a selector. Reach for :is() when you want the rule to hold its ground and :where() when you are writing base styles or a reusable component.

:where(.prose) a {
  color: navy;
}

.task:not(:last-child) {
  border-bottom: 1px solid #ccc;
}

How does :has() select a parent?

For years CSS could only look down the tree or at later siblings. :has() lets a selector depend on what an element contains. .card:has(img) matches cards that contain an image, so you can lay them out differently from text-only cards. .task:has(input:checked) matches a list item whose checkbox is ticked, and the style updates live as the box is ticked. It has been supported in all major browsers since late 2023.

The argument is a relative selector. :has(> img) checks only direct children. :has(+ .error) checks the next sibling, which lets a label react to an error message after it. You can combine it with other pseudo-classes: form:has(:user-invalid) .send targets the button of a form with a field the user got wrong.

:has() can also act on the whole page: body:has(dialog[open]) can stop the page scrolling while a dialog is open. Keep such rules simple, because the browser has to recheck them as the page changes.

.card:has(img) {
  display: grid;
  grid-template-columns: 120px 1fr;
}

.field:has(input:user-invalid) label {
  color: #b5442c;
}

What do people get wrong with :has()?

:has() cannot be nested inside another :has(), and it cannot contain pseudo-elements such as ::before. Its specificity is that of its most specific argument, so .card:has(#hero) carries id-level weight, which can make it hard to override.

A common slip is putting the state on the wrong element: .task input:checked styles the checkbox, while .task:has(input:checked) styles the list item. Read the selector from right to left: the last compound before any combinator is the element that gets the styles.

What should you look for in AI-written state CSS?

Generated CSS often removes focus outlines without a replacement, uses :focus where :focus-visible was meant, styles errors with :invalid so empty forms load covered in red, and misspells pseudo-classes such as :disable or :checked(), which silently drops the whole rule. Look for hover-only menus that touch and keyboard users cannot open.

When a state rule does nothing, check the DevTools Styles panel. A rule with an invalid selector does not appear at all; a valid rule that does not match shows nothing either, so force the state with :hov to confirm the selector before blaming the properties.

Field notes

  • A pseudo-class matches an element by its state or position instead of its name.
  • Touch screens have no real hover, so keep important content out of hover-only styles.
  • Form states like :checked and :disabled can be styled directly.
  • :has() lets a selector match an element based on what it contains.

Easy to get wrong

Removing the outline hides keyboard focus completely. :focus-visible shows a ring for keyboard users only.

:invalid matches empty required fields on page load. :user-invalid waits until the person has interacted with the field.

The first rule styles the checkbox, which has no text. :has() styles the list item that contains it.

Adding a border and removing it again in rules that fight over order is fragile. :not(:last-child) states the intent once.

Questions people ask

Is :has() slow?

Browsers optimise common patterns well. Avoid very broad rules such as *:has(*), and you will not notice a cost.

Why does my :hover style stick on phones?

Touch browsers apply hover on tap and keep it until you tap elsewhere. Wrap hover-only effects in @media (hover: hover).

What is the difference between :nth-child and :nth-of-type?

:nth-child counts every element sibling. :nth-of-type counts only siblings with the same tag name.

Can I style a label when its input is focused?

Yes. label:has(input:focus-visible) or .field:focus-within both work.

Keep going