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
- Put a form on a page with no CSS at all. Note every place your font stops.
- Add
font: inheritand find the<textarea>'s default. - Remove
width: 100%from an input and measure its width against its container. - Set a pale grey border, then check its contrast in devtools. Fix it.
- Set a field's font size to 14px and focus it on a real iPhone.
- Style a
<select>withappearance: noneand an SVG arrow. Then try to style the open dropdown. - Use
:has(option[value=""]:checked)to grey a select's placeholder state. - Recolour a checkbox with
accent-color, on two different operating systems if you can. - Build the custom checkbox. Then toggle it with Space, click its label, and listen to it with a screen reader.
- Build the old hide-the-input-and-style-a-span version and compare all three of those.
- Style a placeholder without
opacity: 1and check it in Firefox. - Build the floating label. Remove
placeholder=" "and explain what breaks. - Style
input:invalidon load, then switch to:user-invalid. - Add an error icon that appears only via
.field:has(input:user-invalid). - Set
resize: none, thenhorizontal, thenvertical. Judge each. - Try
field-sizing: contenton a textarea and check support on caniuse.com. - Build the file-input-as-label pattern and confirm it is keyboard reachable.
- Autofill a form in Chrome and try to override the yellow with
background-color, then with the inset shadow. - Run module 5's keyboard pass over your whole styled form.
Official documentation
- MDN — Styling web forms — What is styleable and what is not, control by control.
- MDN — Advanced form styling —
appearance, and the controls that resist it. - MDN — appearance — Every value, including the newer
base-select. - MDN — accent-color — Which controls it affects.
- MDN — :user-invalid — How it differs from
:invalid. - MDN — field-sizing — Growing a textarea with its content.
- W3C WAI — Forms tutorial — The accessibility side, which every custom control makes your responsibility.
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