Skip to content

Form States — Styling Focus, Hover, Disabled, and Validation

A well-styled form communicates with the user. It shows which field is active, which fields are unavailable, and which fields contain errors — all without a word of JavaScript. CSS pseudo-classes make this possible.

You already styled :focus in Lesson 02. This lesson builds the complete state system.

Think of form field states as a progression:

  1. Default — the field is available but not interacted with
  2. Hover — the user’s cursor is over the field
  3. Focus — the field is active (clicked or tabbed to)
  4. Filled — the user has entered content
  5. Disabled — the field is not available
  6. Invalid — the content does not pass validation

Each state gets its own visual treatment. Together, they create a form that guides users through completion without requiring any explanatory text.

A subtle border color change on hover confirms the field is interactive before the user commits to clicking:

.form-group input:hover,
.form-group textarea:hover,
.form-group select:hover {
border-color: #5a5a5a;
}

#5a5a5a (mid-gray) is a slight darkening of the default warm gray border. The change is noticeable on close inspection but not dramatic — hover is a hint, not a statement.

The order in CSS matters here: :hover should be defined before :focus in the stylesheet so that :focus wins when both are active simultaneously (the user is hovering over a focused field).

From Lesson 02, the focus state uses a border color shift and a custom shadow ring:

.form-group input:focus,
.form-group textarea:focus,
.form-group select:focus {
outline: none;
border-color: #2c4a1e;
box-shadow: 0 0 0 3px rgba(44, 74, 30, 0.2);
}

This is the most important state — it must be clearly visible. The green ring against the warm off-white background of the STO site is high-contrast and immediately recognizable.

:disabled — communicating unavailability

Section titled “:disabled — communicating unavailability”

When a field is not available (because of form logic, user permissions, or a submitted state), its visual appearance should communicate “this cannot be used”:

.form-group input:disabled,
.form-group textarea:disabled,
.form-group select:disabled {
opacity: 0.5;
cursor: not-allowed;
background-color: #eae4da;
}
  • opacity: 0.5 — fades the entire field including its label and text
  • cursor: not-allowed — the prohibited cursor icon
  • background-color: #eae4da — the STO alternate background color; a slight gray to reinforce that the field is inactive

The disabled attribute in HTML automatically prevents user interaction — this CSS only handles the visual signal.

HTML provides built-in validation through attributes:

  • required — the field must not be empty
  • type="email" — the value must look like an email address
  • minlength="N" — the value must be at least N characters
  • pattern="..." — the value must match a regex pattern

CSS can read the validation state:

input:valid {
border-color: #2c4a1e; /* green border for valid input */
}
input:invalid {
border-color: #c0392b; /* red border for invalid input */
}

There is a problem with this approach: :invalid fires immediately when the page loads, before the user has typed anything — required fields are invalid by default when they are empty. This creates a jarring experience where users see red borders the moment they arrive at the form.

The :placeholder-shown pseudo-class fires when the field’s placeholder text is visible — meaning the field is empty. Combining it with :not() and :invalid creates a validation style that only appears after the user has started (and then cleared or left invalid content):

.form-group input:invalid:not(:placeholder-shown),
.form-group textarea:invalid:not(:placeholder-shown) {
border-color: #c0392b;
box-shadow: 0 0 0 3px rgba(192, 57, 43, 0.15);
}

Reading the selector: “An invalid input that is NOT currently showing its placeholder” — meaning the user has typed something, and what they typed does not pass validation.

The practical effect: the red border only appears after the user has interacted with the field and left it in an invalid state. Empty fields that the user has not yet touched show no error styling.

Note: This approach relies on the placeholder attribute being present on the input. Fields without a placeholder will show the error state immediately. Always include placeholder on fields that use this validation pattern.

You can style required fields to give users an upfront signal:

.form-group input:required,
.form-group textarea:required {
border-left: 3px solid #2c4a1e;
}

This adds a green left border accent to required fields. The approach is optional — many forms use an asterisk (*) in the label text instead, which is equally clear and requires no CSS:

<label for="name">Name <span aria-hidden="true">*</span></label>

Both approaches are valid. The asterisk approach is more universally understood and does not require additional CSS.

CSS can style the visual appearance of an invalid field, but it cannot show or hide error message text. Displaying a specific error message (“Please enter a valid email address”) requires a small amount of JavaScript.

The CSS preparation: write the error message in your HTML with a class and set it to display: none by default. JavaScript adds a class to show it when validation fails. Module 07 onwards is where JavaScript enters the picture — for now, the visual state of the field itself communicates the error.

/* Hover */
.form-group input:hover,
.form-group textarea:hover,
.form-group select:hover {
border-color: #5a5a5a;
}
/* Focus */
.form-group input:focus,
.form-group textarea:focus,
.form-group select:focus {
outline: none;
border-color: #2c4a1e;
box-shadow: 0 0 0 3px rgba(44, 74, 30, 0.2);
}
/* Invalid after interaction */
.form-group input:invalid:not(:placeholder-shown),
.form-group textarea:invalid:not(:placeholder-shown) {
border-color: #c0392b;
box-shadow: 0 0 0 3px rgba(192, 57, 43, 0.15);
}
/* Disabled */
.form-group input:disabled,
.form-group textarea:disabled,
.form-group select:disabled {
opacity: 0.5;
cursor: not-allowed;
background-color: #eae4da;
}

Apply state styling to the STO contact form:

  1. Add the complete state ruleset to style.css.

  2. Open contact.html. Hover over the Name field — the border should darken slightly. Click into it — the green focus ring should appear. Click elsewhere — the ring should fade out.

  3. Type an invalid email address in the Email field (e.g. “notanemail”). Click away. The field should show a red border and shadow. Now type a valid email (e.g. “hello@example.com”). The red border should disappear — the :invalid:not(:placeholder-shown) selector no longer matches a valid email.

  4. In DevTools, select the Name input and add the disabled attribute. The field should fade to 50% opacity with the alternate background color.

  5. Clear the Name field entirely and click elsewhere. Because it has the required attribute, it becomes :invalid — but because the placeholder is now showing again, the red border should NOT appear. This confirms the :not(:placeholder-shown) timing fix is working.

  • Form state styling is a system: default → hover → focus → invalid → disabled. Each state should be visually distinct.
  • :hover on inputs signals interactivity with a subtle border darkening — define it before :focus so :focus wins when both are active.
  • :focus must always be visible for keyboard and assistive technology users — the custom box-shadow ring replaces the browser outline.
  • :invalid:not(:placeholder-shown) shows validation errors only after the user has typed and left invalid content, avoiding the “red on arrival” problem.
  • :disabled uses opacity: 0.5, cursor: not-allowed, and a muted background to communicate unavailability.
  • CSS handles the visual state of invalid fields; showing or hiding specific error messages requires JavaScript.

Lesson 06 assembles everything into the complete STO contact form stylesheet.