Basic forms styling in 50 lines of CSS

Styling forms can be cumbersome and can take a lot of time. Of course each person will have its preferences about how it should look and how it should behave. But what if we could have a common base in a few lines of code? One that would allow use to make forms usable even without design? This is what this page is about.

Please note: the code excerpts and visual demos in this page may have different code than what is written.

Basics of forms

I've detailed in my doing forms HTML page what elements are required when doing forms. If you haven't I suggest you read it before pursuing.

To put it quickly:

  1. Everything must be inside a <form>
  2. You can group your inputs in <fieldset> and name those groups with <legend>
  3. Each input must have a <label> referencing its input with an id
  4. Error or information texts for each input can be placed where you want, but must have an ID and be referenced with the aria-describedby attribute.
  5. If an input is invalid, the input must have an aria-invalid attribute.
  6. Validate forms by using a <button>, even if you use JavaScript validation.

So a correct form code should look like this. I position the information text below the label, and the error message below the input as it provides a good user experience for most people.

<form>
  <fieldset>
    <legend>My group</legend>
     
    <label for="input1">Text label</label>
    <span id="input1desc">Description text</span>
    <input id="input1" type="text" aria-describedby="input1desc input1err" aria-invalid>
    <span id="input1err">Error text</span>
     
    <label for="input2">Text label</label>
    <span id="input2desc">Description text</span>
    <input id="input2" type="text" aria-describedby="input2desc input2err" aria-invalid>
    <span id="input2err">Error text</span>
     
  </fieldset>
  <button>Validate form</button>
</form>

And it should visually look like this without any styling:

My group Description text Error text Description text Error text

And the result will be:

The code

Below is the full code for styling forms the lazy way while respecting the markup specified above. See below for a full explanation of what does what.

form * {
  display: block;
}
 
form > * + *,
fieldset > * + * {
  margin-top: 1em;
}
 
label ~ span,
label ~ input,
label ~ select {
  margin-top: 0.25em;
}
 
label ~ span {
  color: #555;
  font-style: italic;
}
 
input + span,
select + span {
  color: red;
  display: none;
}
 
[aria-invalid] + span {
  display: block;
}
 
[data-size] {
  width: 100%;
}
 
[data-size="30"] {
  max-width: 30ch;
}
 
[data-size="10"] {
  max-width: 10ch;
}
 
[type="checkbox"],
[type="radio"],
[type="checkbox"] ~ label,
[type="radio"] ~ label {
  display: inline;
}
 
button,
[type="button"],
[type="submit"],
[type="reset"] {
  all: unset;
  font-size: 1em;
  border: 1px solid currentColor;
  padding: 10px;
}

Code explanation

The first problem to fix is the vertical flow. You want each combination of label, input, information text and error message to be visually distinct from each others. To do this most people group everything into a <div> but you can actually do it in a leaner way by using styling from the parent.

First we transform all children elements of the form into block elements so they stack upon each other. It has been studied that stacking form elements provides a better user experience. It also makes less work for us.

form * {
  display: block;
}

Next we need to give spacings to our form elements and more importantly, we want to give them the same spacing independently of what they are or how they are organized. To do this, we use the adjacent sibling combinator to detect if any element (*) as any adjacent sibling an if it has one, we add a margin top (you may have heard about this as the lobotomized owl technique).

form > * + *,
fieldset > * + * {
  margin-top: 1em;
}

Now we want to group some elements together by reducing the margins of some of our elements appearing after the label. But there's a catch: since we can have several combinations of elements (label then input, label then text then input, etc...) and we don't want to write specific combinations, we need a new combinator.

So this time we use the general sibling combinator to detect any span, input or select than comes after a label` inside the current parent. In the code below, if there's a succession of those two elements, then the second one has a reduced margin top.

label ~ span,
label ~ input,
label ~ select {
  margin-top: 0.25em;
}

We don't want users to struggle identifying which text is the label and which text is informative as they currently have the same style and color. So we stylize the span element adjacent to the label.

label ~ span {
  color: #555;
  font-style: italic;
}

We want to do the same for the error message. But we also want to hide it by default and only make it appear when there's an error. So for now, it's gone.

input + span,
select + span {
  color: red;
  display: none;
}

When a form has an incorrect value, it's standard practice to add the aria-invalid attribute to the input or select that triggered it. Since we know this attribute is going to appear, we can then trigger the display of the error message using the adjacent sibling selector again.

[aria-invalid] + span {
  display: block;
}

Right now all inputs have the same default size. We want to be able to change it according to our needs. Instead of using pixels, we can use the ch unit, that stands for character. 1ch means one time the larger of 0 in the current font.

We can use data attributes to create alternate versions.

[data-size] {
  width: 100%;
}
 
[data-size="30"] {
  max-width: 30ch;
}
 
[data-size="10"] {
  max-width: 10ch;
}

Now comes the problem of checkbox and radio inputs. They usually come first with their label following. So we need to de-stack them by turning them into inline elements.

We could have used the :not() pseudo-class in our first code block above to exclude the checkbox and radio inputs from the display:block; rule. But doing so increases specificity and makes the code less readable, so it's better to let the cascade do its own thing.

<form>
    <fieldset>
      <legend>My checkbox group</legend>
      <input id="checkbox1" type="checkbox">
      <label for="checkbox1">My input</label>
      <input id="checkbox2" type="checkbox">
      <label for="checkbox2">My input</label>
    </fieldset>
  <button>Validate form</button>
</form>
[type="checkbox"],
[type="radio"],
[type="checkbox"] ~ label,
[type="radio"] ~ label {
  display: inline;
}

Finally, when you are styling your buttons, don't forget to also style the special input types that can be used as buttons.

<input type="button" value="Input Button">
<input type="submit" value="Input Submit">
<button type="submit" >Submit</button>
<input type="reset" value="Input Reset">
<button type="reset">Reset</button>
<button disabled="">Cancel</button>
button,
[type="button"],
[type="submit"],
[type="reset"] {
  all: unset;
  font-size: 1em;
  border: 1px solid currentColor;
  padding: 10px;
}
Statistics for: Basic forms styling (click to see more) Time spent
Time spent
5 hours total
Started
week 15 of 2022
Last update
week 15 of 2022
All times entries for this page
Year Week Hours
2022155