Codeskill

Learn to code, step by step

Forms and UI states

Next: styling forms and UI states in CSS: focus rings that help keyboard users, invalid and valid feedback that does not shout too early, and disabled controls that look disabled without breaking contrast rules. The intro series touched form styling; here the emphasis is on state pseudo-classes and accessible patterns..

Base form control styles

Start with a consistent baseline for inputs, selects, and textareas:

.field input,
.field select,
.field textarea {
  display: block;
  inline-size: 100%;
  padding: 0.5rem 0.75rem;
  font: inherit;
  color: inherit;
  background: var(--color-surface, #fff);
  border: 1px solid #d1d5db;
  border-radius: 0.375rem;
}

.field textarea {
  min-block-size: 6rem;
  resize: vertical;
}

Inherit font and colour so controls match the page. Set display: block and full inline size for predictable layout in stacked forms.

:focus vs :focus-visible

:focus matches whenever an element has focus, including mouse clicks. That often produces a ring on every click, which annoys sighted mouse users. :focus-visible matches when the browser decides focus should be shown – typically keyboard navigation:

.field input:focus {
  outline: none; /* remove default only if you replace it */
}

.field input:focus-visible {
  outline: 2px solid var(--color-accent);
  outline-offset: 2px;
}

Never remove focus styles without a visible replacement. Keyboard users depend on them. A common pattern: reset outline on :focus, restore on :focus-visible.

:focus-within for field wrappers

Highlight the whole field group when any control inside is focused:

.field {
  padding: 0.75rem;
  border: 1px solid #e5e7eb;
  border-radius: 0.5rem;
  transition: border-color 0.15s;
}

.field:focus-within {
  border-color: var(--color-accent);
  box-shadow: 0 0 0 3px rgb(3 102 214 / 0.15);
}

Do not rely on colour alone – the border weight or shadow change helps too.

Valid and invalid states

:valid and :invalid reflect constraint validation. They fire immediately on page load for required empty fields, which is often too aggressive. Prefer :user-valid and :user-invalid where supported – they wait until the user has interacted:

.field input:user-invalid {
  border-color: #dc2626;
}

.field input:user-invalid:focus-visible {
  outline-color: #dc2626;
}

.field input:user-valid:not(:placeholder-shown) {
  border-color: #16a34a;
}

Pair with :has() on the wrapper (see the modern selectors tutorial) to style labels or show icons beside invalid fields.

:disabled and :read-only

.field input:disabled,
.field select:disabled,
.field textarea:disabled {
  opacity: 0.6;
  cursor: not-allowed;
  background: #f3f4f6;
}

.field input:read-only:not(:disabled) {
  background: #f9fafb;
  border-style: dashed;
}

Disabled fields are not submitted and should not be focusable. Read-only fields are still submitted; style them differently so users know they cannot edit but can copy.

Placeholder styling

.field input::placeholder {
  color: #9ca3af;
  opacity: 1; /* Firefox defaults to lower opacity */
}

Placeholders are hints, not labels. Keep contrast reasonable but visually secondary. Never use placeholder as the only accessible name.

Checkboxes, radios, and custom appearance

Native checkboxes are accessible if you do not hide them improperly. Style with appearance: none only when you rebuild focus and checked states:

.choice input[type="checkbox"] {
  appearance: none;
  inline-size: 1.25rem;
  block-size: 1.25rem;
  border: 2px solid #d1d5db;
  border-radius: 0.25rem;
  display: grid;
  place-content: center;
}

.choice input[type="checkbox"]:checked {
  background: var(--color-accent);
  border-color: var(--color-accent);
}

.choice input[type="checkbox"]:checked::before {
  content: "";
  inline-size: 0.35rem;
  block-size: 0.6rem;
  border: 2px solid #fff;
  border-block-start: 0;
  border-inline-start: 0;
  transform: rotate(45deg) translate(-1px, -1px);
}

.choice input[type="checkbox"]:focus-visible {
  outline: 2px solid var(--color-accent);
  outline-offset: 2px;
}

Keep the real input in the DOM and clickable. Label wrapping or for/id pairing still required.

Buttons and pressed states

.button {
  padding: 0.5rem 1rem;
  border: none;
  border-radius: 0.375rem;
  background: var(--color-accent);
  color: #fff;
  cursor: pointer;
}

.button:hover {
  filter: brightness(1.05);
}

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

.button:disabled {
  opacity: 0.5;
  cursor: not-allowed;
  transform: none;
}

.button[aria-pressed="true"] {
  box-shadow: inset 0 2px 4px rgb(0 0 0 / 0.2);
}

Toggle buttons use aria-pressed, not a second class alone, so assistive tech knows the state.

The next tutorial covers user preferences: reduced motion and colour scheme – CSS that respects system settings.

PreviousScroll-driven and sticky patterns