CSS Input States with Pseudo-Classes

Form inputs aren’t static boxes — they change appearance as a user hovers over them, clicks into them, fills them in correctly or incorrectly, or gets disabled by JavaScript. CSS gives you a whole family of pseudo-classes that target these states directly, without a single line of JavaScript. Mastering them is the difference between a form that feels alive and responsive and one that feels dead and confusing to use.

Overview / How it works

A pseudo-class is a keyword added to a selector, starting with a single colon, that selects an element based on a state rather than its tag, class, or attributes in the markup. States are dynamic: the browser recalculates which pseudo-classes match an element continuously, as the user interacts with the page. When you hover your mouse over an <input>, the browser engine flags that element as matching :hover and triggers a style recalculation and repaint. When you tab into it, it starts matching :focus. When you type invalid data into a field with a type=\"email\" or a pattern attribute, it starts matching :invalid. None of this touches the DOM structure — it’s the rendering engine re-evaluating selectors against live element state on every relevant event (mouseenter, mouseleave, focus, blur, input, and so on).

Because pseudo-classes are just selectors, ordinary cascade and specificity rules apply. :hover, :focus, :active, :disabled, :checked, and similar single pseudo-classes each contribute the same specificity as a regular class selector (0,1,0). That means source order matters when two pseudo-class rules could both match the same element at the same time — the one declared later in the stylesheet wins if specificity is tied. This is why the classic link-styling mnemonic \”LVHA\” (:link, :visited, :hover, :active) exists for anchors, and a similar ordering discipline matters for inputs: if you declare :hover after :focus in your stylesheet, a focused-and-hovered input will show the hover style instead of the focus style, which is usually not what you want.

Form-specific pseudo-classes fall into a few logical groups: user-interaction states (:hover, :active, :focus, :focus-visible, :focus-within), UI states reflecting the element’s own condition (:disabled, :enabled, :read-only, :read-write, :checked, :indeterminate, :default), and validation states driven by HTML5 constraint validation (:required, :optional, :valid, :invalid, :in-range, :out-of-range). All of them can be combined with each other and with ordinary selectors using chaining (no space) or combinators.

Syntax

selector:pseudo-class {\n  property: value;\n}
  • selector — any valid selector: a tag name (input), a class (.form-field), an attribute selector (input[type=\"email\"]), or nothing at all if you want the pseudo-class alone to match (rare in practice).
  • :pseudo-class — one keyword, always starting with a single colon (not to be confused with pseudo-elements like ::placeholder, which use two colons and target a generated sub-part of the element rather than a state).
  • Pseudo-classes can be chained: input:focus:invalid matches an input that is simultaneously focused and failing validation.
  • Pseudo-classes can be negated with :not(): input:not(:disabled) matches any input that is not disabled.
Pseudo-class Matches when…
:hover the pointer is over the element
:focus the element has keyboard/programmatic focus
:focus-visible focused AND the browser judges a visible indicator is needed (e.g. keyboard nav)
:focus-within the element or any descendant has focus (great on a <fieldset> or wrapper)
:active the element is being actively pressed/clicked
:disabled / :enabled the element has/lacks the disabled attribute
:checked a checkbox, radio, or option is selected
:indeterminate a checkbox in the mixed/indeterminate state (set via script)
:required / :optional the field has/lacks the required attribute
:valid / :invalid the field’s value does/doesn’t satisfy HTML5 constraint validation
:placeholder-shown the field is empty and its placeholder text is visible

Examples

Example 1: Text input across hover, focus, and active

This targets a plain text input, styling it differently as the mouse moves over it, as it receives focus, and at the instant it’s clicked.

.text-field {\n  border: 1px solid #94a3b8;\n  border-radius: 6px;\n  padding: 0.6em 0.8em;\n  outline: none;\n  transition: border-color 0.15s ease, box-shadow 0.15s ease;\n}\n\n.text-field:hover {\n  border-color: #64748b;\n}\n\n.text-field:focus {\n  border-color: #2563eb;\n  box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.25);\n}\n\n.text-field:active {\n  border-color: #1d4ed8;\n}

Result: The input shows a light slate border normally. Hovering darkens the border slightly. Clicking into it (or tabbing to it) swaps the border to blue and adds a soft blue glow ring around the box — a common, accessible focus treatment. The moment the mouse button is down inside it, the border darkens further to a deeper blue.

Notice the order: :hover is declared before :focus, so if both match at once (mouse resting over a focused field), the focus rule wins because it comes later with equal specificity — exactly the behavior you want, since focus is the more important state to communicate.

Example 2: Validation feedback with :required, :valid, and :invalid

Applied to an <input type=\”email\” required>, this gives instant visual feedback about whether the current value passes constraint validation, but only once the user has actually typed something.

input[type=\"email\"] {\n  border: 2px solid #cbd5e1;\n  border-radius: 6px;\n  padding: 0.5em 0.75em;\n}\n\ninput[type=\"email\"]:required {\n  background-image: linear-gradient(to right, transparent 0%, transparent 100%);\n}\n\ninput[type=\"email\"]:focus:invalid {\n  border-color: #dc2626;\n}\n\ninput[type=\"email\"]:not(:placeholder-shown):invalid {\n  border-color: #dc2626;\n  background-color: #fef2f2;\n}\n\ninput[type=\"email\"]:not(:placeholder-shown):valid {\n  border-color: #16a34a;\n  background-color: #f0fdf4;\n}

Result: While the field is empty (placeholder visible), it stays neutral grey — :invalid technically matches an empty required field, but the :not(:placeholder-shown) guard suppresses the red styling until the user has typed something. Once text is entered, the border and background turn red with an invalid-looking value (e.g. \”bob\”) and green once it becomes a syntactically valid email address (e.g. \”bob@example.com\”).

This :not(:placeholder-shown) trick is the standard workaround for the biggest annoyance with :invalid: by default it matches empty required fields immediately, which makes every required field look broken before the user has even started typing.

Example 3: Custom checkbox appearance with :checked and :disabled

Native checkboxes are hard to restyle directly, so a common pattern hides the real checkbox visually (while keeping it accessible) and paints a custom box next to it using a sibling selector driven by :checked.

.toggle input[type=\"checkbox\"] {\n  position: absolute;\n  opacity: 0;\n  width: 1px;\n  height: 1px;\n}\n\n.toggle .box {\n  display: inline-block;\n  width: 1.25em;\n  height: 1.25em;\n  border: 2px solid #94a3b8;\n  border-radius: 4px;\n  vertical-align: middle;\n}\n\n.toggle input[type=\"checkbox\"]:checked + .box {\n  background-color: #2563eb;\n  border-color: #2563eb;\n}\n\n.toggle input[type=\"checkbox\"]:disabled + .box {\n  border-color: #e2e8f0;\n  background-color: #f1f5f9;\n  cursor: not-allowed;\n}\n\n.toggle input[type=\"checkbox\"]:focus-visible + .box {\n  outline: 2px solid #2563eb;\n  outline-offset: 2px;\n}

Result: The real checkbox is invisible but still present and clickable/tabbable. Its adjacent .box span shows an empty grey-bordered square by default, turns solid blue the instant the checkbox becomes checked, turns pale grey and unclickable-looking when disabled, and gains a visible blue outline ring when reached via keyboard (but not when merely clicked with a mouse).

How it works step by step

  • 1. Event fires. The user moves the mouse, presses Tab, clicks, or types — the browser’s input layer generates the corresponding DOM event (mouseover, focus, mousedown, input).
  • 2. State flags update. The rendering engine updates internal flags on the element: \”is hovered,\” \”is focused,\” \”is checked,\” and, for form controls, re-runs constraint validation to update \”is valid.\”
  • 3. Selector matching re-runs. Any CSS rule whose selector includes a pseudo-class that could be affected is re-evaluated against the element (browsers optimize this heavily so it doesn’t mean re-matching the whole stylesheet against the whole DOM every time).
  • 4. Style recalculation. Matching declarations are collected, sorted by the cascade (origin, specificity, source order), and the winning value for each property is computed — exactly as with any other style change.
  • 5. Layout and paint. If the winning styles change geometry (border width, padding) a layout pass runs; if they only change color or shadow, the engine can skip straight to paint/compositing, which is why hover/focus transitions are usually cheap and smooth.

Common Mistakes

Mistake 1: Removing the focus outline without replacing it

Many developers reset outline: none on :focus to \”clean up\” the default browser ring, but forget that this ring is the primary way keyboard users see where they are on the page.

input:focus {\n  outline: none;\n}

This silently strips all focus indication for keyboard and switch-device users, making the form effectively unusable without a mouse. Fix it by replacing the outline with your own visible indicator, and prefer :focus-visible so mouse clicks don’t get an unnecessary ring:

input:focus {\n  outline: none;\n}\n\ninput:focus-visible {\n  outline: 2px solid #2563eb;\n  outline-offset: 2px;\n}

Mistake 2: Styling :hover but forgetting keyboard/focus users entirely

It’s easy to test a form only with a mouse and ship hover-only feedback, leaving keyboard users with no visual cue that a field is active.

.button:hover {\n  background-color: #1d4ed8;\n}

A keyboard user tabbing through the form sees no state change at all until they hover with a mouse, which they may never do. Always pair interaction states so both pointer and keyboard users get feedback:

.button:hover,\n.button:focus-visible {\n  background-color: #1d4ed8;\n}

Best Practices

  • Always style :focus or :focus-visible — never ship a form where keyboard users can’t tell which field they’re in.
  • Prefer :focus-visible over :focus for outline/ring treatments so mouse clicks don’t get a ring meant for keyboard navigation, but keep using :focus for things like showing helper text that should appear regardless of input method.
  • Guard :invalid styling with :not(:placeholder-shown) or apply it only after a form submission attempt, so empty required fields don’t look broken on page load.
  • Use :disabled to lower opacity and set cursor: not-allowed so disabled controls read as non-interactive at a glance.
  • Use :focus-within on a wrapping <fieldset> or <div> to highlight an entire group (like a labeled input row) when any control inside it is focused.
  • Keep transitions short (100–200ms) on color/border/box-shadow changes so state feedback feels immediate, not sluggish.
  • Don’t rely on color alone to convey valid/invalid state — pair it with an icon, text, or border-style change for colorblind users.

Practice Exercises

  • Exercise 1: Style a text input so it has a grey border by default, a blue border on :hover, and a blue border plus a soft box-shadow ring on :focus-visible. Confirm mouse clicks don’t trigger the ring but Tab key navigation does.
  • Exercise 2: Build a password field that shows a red border via :invalid only once the user has typed at least one character (hint: combine :invalid with :not(:placeholder-shown)), and a green border once it satisfies a minlength requirement.
  • Exercise 3: Style a <fieldset> containing three radio inputs so the whole fieldset gets a highlighted background using :focus-within whenever any radio inside it has focus, and each selected radio’s label turns bold using an adjacent :checked sibling selector.

Summary

  • Pseudo-classes select elements by dynamic state, not by markup structure, and each single pseudo-class has the specificity of a class selector.
  • Interaction states — :hover, :focus, :focus-visible, :focus-within, :active — let you give real-time feedback to both pointer and keyboard users.
  • UI-state pseudo-classes — :disabled, :checked, :indeterminate — reflect the control’s own condition, often used with sibling selectors to restyle custom checkbox/radio widgets.
  • Validation pseudo-classes — :required, :valid, :invalid — hook directly into HTML5 constraint validation, but :invalid needs the :not(:placeholder-shown) guard to avoid flagging empty required fields prematurely.
  • Never remove a focus indicator without providing a replacement — it’s the main accessibility signal for non-mouse users.