RizTech Academy logo
RizTech Academy
Modern CSS and WorkflowLesson 3 of 535 min

Styling forms, which is harder than it looks

Forms are the hardest thing in CSS. Every other element is a box you style; a form control is a box the operating system draws, with its own internal parts you cannot reach, and it looks different on every platform. This lesson is the parts that are actually possible and the parts that are not.

Module 1 covered form markup — labels, types, autocomplete, validation. This is the appearance.

Why they are different

A <select> on macOS, Windows and Android are three different drawings, made by three different pieces of software. The browser hands rendering to the platform, which is why your carefully chosen font stops at the edge of every control until you ask for it.

Three consequences:

Form controls do not inherit font. From module 2, and it is the first line of any form CSS:

input, button, select, textarea {
  font: inherit;
  color: inherit;
}

Some internal parts are unreachable. The dropdown list a <select> opens is drawn by the OS. You cannot style it, and no amount of CSS will let you.

appearance is the switch that turns the platform drawing off:

input, select, textarea {
  appearance: none;
  -webkit-appearance: none;    /* older Safari */
}

After that you are drawing everything yourself — including, on a <select>, the arrow.

The base styles worth having

:root {
  --field-border: #767676;      /* 4.5:1 on white — a boundary must be visible */
  --field-radius: 6px;
  --field-pad: 0.625rem 0.75rem;
}

input, select, textarea {
  font: inherit;
  color: inherit;
  appearance: none;
  width: 100%;
  padding: var(--field-pad);
  border: 1px solid var(--field-border);
  border-radius: var(--field-radius);
  background: var(--colour-bg);
}

input:focus-visible,
select:focus-visible,
textarea:focus-visible {
  outline: 2px solid var(--colour-accent);
  outline-offset: 2px;
  border-color: var(--colour-accent);
}

Four things in there that are not obvious:

width: 100% because an <input> has a default width of about 20 characters from its size attribute, not from its container. Without this your fields are all the same odd width regardless of layout.

font: inherit on <textarea> matters most of all — its default is monospace.

The border colour is #767676, not a pale grey. Module 5's non-text contrast requirement is 3:1 for a component boundary, and a field you cannot see the edge of is a field some people cannot find.

outline for focus, not just a border colour change. A border colour change alone is a colour-only signal, which module 5 ruled out.

The 16px rule

input, select, textarea {
  font-size: max(1rem, 16px);
}

From module 4's testing lesson: iOS Safari zooms the page when a field with text under 16px is focused. The layout jumps and it looks broken. max(1rem, 16px) keeps it at least 16 even if your root size is smaller.

<select>: the arrow, and the limits

select {
  appearance: none;
  padding-inline-end: 2.5rem;
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 8'%3E%3Cpath d='M1 1l5 5 5-5' stroke='%231f1f1f' stroke-width='2' fill='none'/%3E%3C/svg%3E");
  background-repeat: no-repeat;
  background-position: right 0.75rem center;
  background-size: 12px 8px;
}

appearance: none removes the platform arrow, so you draw one. An inline SVG data URI is the usual way — note the %23 for # in the colour, because # starts a fragment in a URL.

What you still cannot style: the open dropdown list, the <option> elements themselves (beyond color and background-color, inconsistently), and <optgroup> labels. The newer appearance: base-select is arriving to fix exactly this — check caniuse.com before relying on it — and until then a fully styled dropdown means building a custom widget, which module 5's ARIA lesson argued against for good reason.

Two more details:

select:has(option[value=""]:checked) { color: var(--colour-text-muted); }

That greys the select while the empty placeholder option is chosen — the :has() from the previous lesson doing something genuinely useful, since there is no :placeholder-shown for <select>.

And a multi-line <select multiple> ignores most of this; it is a list box, drawn differently again.

Checkboxes and radios

Two approaches, and the modern one is much better.

accent-color, when the default shape is fine

input[type="checkbox"],
input[type="radio"] {
  accent-color: var(--colour-accent);
  width: 1.15rem;
  height: 1.15rem;
}

One property, and it recolours the native control — including its checked state — on every platform, while keeping all the native behaviour and accessibility. Try this first. It also applies to <progress> and range sliders.

A custom drawing, when the design demands it

input[type="checkbox"] {
  appearance: none;
  width: 1.25rem;
  height: 1.25rem;
  border: 2px solid var(--field-border);
  border-radius: 4px;
  display: grid;
  place-content: center;
  flex: 0 0 auto;       /* stop it shrinking in a flex row */
}

input[type="checkbox"]::before {
  content: "";
  width: 0.7rem;
  height: 0.7rem;
  clip-path: polygon(14% 44%, 0 65%, 50% 100%, 100% 16%, 80% 0%, 43% 62%);
  transform: scale(0);
  background: white;
}

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

input[type="checkbox"]:checked::before {
  transform: scale(1);
}

The important part is what this keeps: it is still a real <input type="checkbox">, so Space toggles it, the label click target works, it is announced correctly, and it submits. The old technique — hiding the input and styling a <span> — loses most of that.

For a radio, the same with border-radius: 50% and a filled dot.

And the layout that makes the whole row a target:

.check {
  display: flex;
  align-items: start;
  gap: 0.625rem;
  cursor: pointer;
}
<label class="check">
  <input type="checkbox" name="delivery">
  <span>Deliver between 5pm and 8pm</span>
</label>

Wrapping in the <label> means the text is the target too, which on a phone is the difference between usable and infuriating — module 1's point, now with the CSS.

Placeholders, and the better pattern

input::placeholder,
textarea::placeholder {
  color: var(--colour-text-muted);   /* must still pass 4.5:1 */
  opacity: 1;                        /* Firefox applies its own opacity */
}

opacity: 1 is needed because Firefox dims placeholders further, so a contrast-checked colour fails there.

But the module 1 rule stands: a placeholder is not a label. The floating-label pattern, which keeps a real label and still gives you the compact look:

.field { position: relative; }

.field label {
  position: absolute;
  inset-inline-start: 0.75rem;
  inset-block-start: 0.65rem;
  color: var(--colour-text-muted);
  transition: 150ms;
  pointer-events: none;
  background: var(--colour-bg);
  padding-inline: 0.25rem;
}

.field:focus-within label,
.field:has(input:not(:placeholder-shown)) label {
  inset-block-start: -0.55rem;
  font-size: 0.8rem;
  color: var(--colour-accent);
}
<div class="field">
  <input id="name" type="text" placeholder=" ">
  <label for="name">Your name</label>
</div>

Two tricks in there. placeholder=" " — a single space — so :placeholder-shown is true when empty and false once typed, which is how the label knows to move. And :focus-within plus :has() from the previous lesson, with no JavaScript at all.

Wrap the transition in prefers-reduced-motion: no-preference if you want to be careful.

Validation states

input:user-invalid {
  border-color: var(--colour-error);
}

input:user-valid {
  border-color: var(--colour-success);
}

.field:has(input:user-invalid) .hint {
  color: var(--colour-error);
}

:user-invalid, never :invalid — module 1's point: plain :invalid turns every required field red before the visitor has typed anything.

And colour is not enough. Module 5's rule means an icon or a message too:

.field:has(input:user-invalid) .field__icon { display: block; }

<textarea>

textarea {
  font: inherit;               /* its default is monospace */
  resize: vertical;            /* horizontal resizing breaks layouts */
  min-height: 6rem;
  field-sizing: content;       /* grows with its content — new, check support */
}

resize: vertical rather than none. Removing resizing entirely takes away something people use; letting it resize horizontally breaks your layout. Vertical only is the answer.

field-sizing: content makes a textarea grow as you type, which previously needed JavaScript measuring the scroll height. It is recent — check support.

<input type="file"> and the other stubborn ones

input[type="file"] { display: none; }
<label class="button" for="photo">Choose a photo</label>
<input type="file" id="photo">

The file input's button is drawn by the browser and largely unstyleable — ::file-selector-button gives you some control in current browsers, and the label-as-button pattern above is the reliable route. The label still triggers the input, so it stays keyboard accessible.

display: none is safe here only because the <label> provides the accessible name and the click target. Do not copy the technique to hide other inputs.

Others with limits: type="date" and type="time" open OS pickers you cannot style; type="color" opens the OS colour picker; type="range" needs ::-webkit-slider-thumb and friends, though accent-color handles the common case.

Autofill

input:autofill {
  box-shadow: 0 0 0 100px var(--colour-bg) inset;
  -webkit-text-fill-color: var(--colour-text);
}

Chrome applies a yellow background to autofilled fields that ordinary background-color cannot override. The inset-shadow trick is the workaround, and -webkit-text-fill-color is needed because color is also overridden.

Do not fight it too hard. The yellow is a signal to the user that the value came from their password manager, and module 1's argument for autocomplete was that autofill is worth encouraging.

A note on effort

This lesson is long because forms are genuinely the worst of it, and the honest advice is: style the base styles once, well, and reuse them. The block at the top of this lesson plus accent-color covers most real needs. Reach for a fully custom checkbox or a floating label when a design requires it, not by default.

And every custom control you draw is a control whose keyboard behaviour and announcement you are now responsible for verifying. Module 5's testing passes apply to forms more than anywhere else.

Check your work

Why form controls look different everywhere. The platform draws them, not the browser.

The first line of any form CSS. font: inherit on inputs, buttons, selects and textareas.

What appearance: none commits you to. Drawing everything yourself, including the select arrow.

Why width: 100% on an input. Its default width comes from size, not its container.

Why the border must be #767676 or darker. A component boundary needs 3:1, and 4.5:1 is safer.

Why max(1rem, 16px) on fields. iOS Safari zooms on focus below 16px.

What you still cannot style on a <select>. The open dropdown list and the options.

What to try before a custom checkbox. accent-color.

What a custom checkbox built on the real input keeps. Space to toggle, the label target, correct announcement, and submission.

Why opacity: 1 on a placeholder. Firefox dims it further.

The two tricks in a floating label. placeholder=" " so :placeholder-shown works, and :focus-within plus :has().

Why :user-invalid. :invalid turns fields red before anybody has typed.

Why resize: vertical. none removes something useful; horizontal breaks layouts.

Why display: none is acceptable on a file input. The <label> supplies the name and the click target — do not generalise it.

Why autofill needs an inset shadow. Chrome's yellow overrides background-color.

Practice

  1. Put a form on a page with no CSS at all. Note every place your font stops.
  2. Add font: inherit and find the <textarea>'s default.
  3. Remove width: 100% from an input and measure its width against its container.
  4. Set a pale grey border, then check its contrast in devtools. Fix it.
  5. Set a field's font size to 14px and focus it on a real iPhone.
  6. Style a <select> with appearance: none and an SVG arrow. Then try to style the open dropdown.
  7. Use :has(option[value=""]:checked) to grey a select's placeholder state.
  8. Recolour a checkbox with accent-color, on two different operating systems if you can.
  9. Build the custom checkbox. Then toggle it with Space, click its label, and listen to it with a screen reader.
  10. Build the old hide-the-input-and-style-a-span version and compare all three of those.
  11. Style a placeholder without opacity: 1 and check it in Firefox.
  12. Build the floating label. Remove placeholder=" " and explain what breaks.
  13. Style input:invalid on load, then switch to :user-invalid.
  14. Add an error icon that appears only via .field:has(input:user-invalid).
  15. Set resize: none, then horizontal, then vertical. Judge each.
  16. Try field-sizing: content on a textarea and check support on caniuse.com.
  17. Build the file-input-as-label pattern and confirm it is keyboard reachable.
  18. Autofill a form in Chrome and try to override the yellow with background-color, then with the inset shadow.
  19. Run module 5's keyboard pass over your whole styled form.

Official documentation

Next: transitions and animation, used with restraint.

Stuck on this lesson?

Being stuck is part of it — but being stuck alone for three days is not. Our internship programme pairs this curriculum with code review and one-to-one help from working developers, and it is free.

About the internship