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:invalidmatches 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
:focusor:focus-visible— never ship a form where keyboard users can’t tell which field they’re in. - Prefer
:focus-visibleover:focusfor outline/ring treatments so mouse clicks don’t get a ring meant for keyboard navigation, but keep using:focusfor things like showing helper text that should appear regardless of input method. - Guard
:invalidstyling 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
:disabledto lower opacity and setcursor: not-allowedso disabled controls read as non-interactive at a glance. - Use
:focus-withinon 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
:invalidonly once the user has typed at least one character (hint: combine:invalidwith:not(:placeholder-shown)), and a green border once it satisfies aminlengthrequirement. - Exercise 3: Style a <fieldset> containing three radio inputs so the whole fieldset gets a highlighted background using
:focus-withinwhenever any radio inside it has focus, and each selected radio’s label turns bold using an adjacent:checkedsibling 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:invalidneeds 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.
