Styling forms

Style form controls so they match the page and still behave like controls.

23of 26
≈ 10Min read
8Parts

The line everyone forgets

Form controls do not inherit the page font. Left alone, an <input> uses the operating system UI font at about 13px, ignoring everything you set on body.

one rule, immediately better
input, button, textarea, select {
  font: inherit;
  color: inherit;
}
And the iOS zoom rule

Safari on iPhone zooms the page when you focus a field whose font size is under 16px. Keep inputs at 16px or larger and it stops happening — it is not a bug you can style around.

Removing the native look

appearance: none takes the operating system styling off so you can draw your own. It also takes away everything that styling communicated — so whatever you draw has to show focus, checked, disabled and invalid states itself.

Two things you get for free without appearance: none: accent-color tints a native checkbox, radio, range or progress in one line, and the control keeps its platform behaviour.

appearance
input[type="checkbox"],
input[type="radio"],
select {
  appearance: none;   /* strip the platform widget */
}
the cheap version that is usually enough
:root {
  accent-color: #f1521f;
}

A text field

Removing outline is only acceptable when something else takes its job — here the border plus a ring. A focus state that is only a colour change fails the 3:1 contrast requirement for non-text indicators.

the whole thing
.field {
  width: 100%;
  padding: 0.7rem 0.9rem;
  font: inherit;
  color: inherit;
  background: var(--bg-2);
  border: 1px solid var(--line);
  border-radius: 8px;
  transition: border-color 150ms, box-shadow 150ms;
}

.field::placeholder { color: var(--soft); }

.field:hover { border-color: var(--line-2); }

.field:focus-visible {
  outline: none;                       /* only because we replace it below */
  border-color: var(--accent);
  box-shadow: 0 0 0 3px var(--accent-soft);
}

.field:disabled {
  opacity: 0.55;
  cursor: not-allowed;
}

.field:user-invalid {
  border-color: var(--danger);
}

A custom checkbox

the markup stays native
<label class="check">
  <input type="checkbox" name="terms" value="yes">
  <span class="box" aria-hidden="true"></span>
  <span>I agree to the terms</span>
</label>
style.css
.check { display: flex; align-items: center; gap: 0.7rem; cursor: pointer; }

/* keep the input focusable and in the accessibility tree: never display: none */
.check input {
  position: absolute;
  opacity: 0;
  width: 1.15rem;
  height: 1.15rem;
  margin: 0;
}

.check .box {
  width: 1.15rem;
  height: 1.15rem;
  border: 1px solid var(--line-2);
  border-radius: 5px;
  display: grid;
  place-items: center;
  transition: background 150ms, border-color 150ms;
}

.check .box::after {
  content: "";
  width: 0.55rem;
  height: 0.3rem;
  border-left: 2px solid var(--accent-ink);
  border-bottom: 2px solid var(--accent-ink);
  rotate: -45deg;
  opacity: 0;
  transition: opacity 120ms;
}

.check input:checked + .box { background: var(--accent); border-color: var(--accent); }
.check input:checked + .box::after { opacity: 1; }
.check input:focus-visible + .box { outline: 2px solid var(--accent); outline-offset: 2px; }
.check input:disabled + .box { opacity: 0.5; }
Never hide the input with display: none

That removes it from the tab order and from the accessibility tree — the control stops existing for a keyboard or a screen reader. opacity: 0 with a real size keeps it working and invisible.

A select

The closed select is stylable. The open dropdown list is drawn by the operating system and has never been stylable by ordinary CSS — that is what sends people to JavaScript combobox libraries, which then have to rebuild keyboard support, typeahead and mobile behaviour from nothing.

the arrow has to be drawn
.select {
  appearance: none;
  padding-right: 2.2rem;
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8'%3E%3Cpath d='M1 1l5 5 5-5' fill='none' stroke='%23888' stroke-width='2'/%3E%3C/svg%3E");
  background-repeat: no-repeat;
  background-position: right 0.9rem center;
}

Range, progress and file

A file input cannot be styled at all. The pattern is a visually hidden input and a <label> styled as a button — the label is already the trigger, so no JavaScript is needed.

the vendor parts
input[type="range"] {
  appearance: none;
  height: 1.5rem;
  background: transparent;
}

input[type="range"]::-webkit-slider-runnable-track {
  height: 4px;
  border-radius: 999px;
  background: var(--line-2);
}

input[type="range"]::-webkit-slider-thumb {
  appearance: none;
  width: 18px;
  height: 18px;
  margin-top: -7px;
  border-radius: 50%;
  background: var(--accent);
}

input[type="range"]::-moz-range-thumb { /* Firefox needs its own */ }

Layout

  • Labels above fields scan faster than beside them, and they survive a narrow screen without a media query.
  • Give the error message a stable place so the layout does not jump when it appears — min-height on its container.
  • Group related fields in a fieldset and style the legend; do not replace them with a heading.
a form that does not fight you
.form {
  display: grid;
  gap: 1.1rem;
}

.form label {
  display: grid;
  gap: 0.4rem;
  font-size: 0.9rem;
  font-weight: 500;
}

.row {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(14rem, 1fr));
  gap: 1.1rem;
}

The states you must not forget

  • :hover — a hint that it is interactive.
  • :focus-visible — required, and it must reach 3:1 contrast.
  • :disabled — visibly inert, and cursor: not-allowed.
  • :user-invalid — after interaction only, with a text message as well as the colour.
  • :checked and :indeterminate for boxes.
  • ::placeholder — and remember it is a hint, never the label.

Practice