Module 9: Real-World Usage
Styling Forms and Inputs
Why form controls resist styling, how to normalize them, how to build validation states and custom checkboxes, and the details that keep forms usable on phones.
Forms are where CSS meets real user input, and where most visual polish goes to die. Form controls are rendered by the browser, often using native operating system widgets, and they ignore a lot of what you set on the page.
Controls do not inherit your typography#
Put font-family: Inter on body and look at a button or input. It still uses the system font. Browsers give form controls their own font settings in the user-agent stylesheet, and those override inheritance.
The standard fix is to tell them to inherit:
button, input, select, textarea {
font: inherit;
color: inherit;
}
This one rule makes controls match the rest of the page. It belongs in your base styles.
A solid base for text inputs#
.field {
display: grid;
gap: 0.35rem;
}
.field label { font-weight: 600; }
.input {
width: 100%;
padding: 0.6rem 0.75rem;
border: 1px solid #9ca3af;
border-radius: 8px;
background: #fff;
font: inherit;
line-height: 1.4;
}
.input::placeholder { color: #6b7280; }
.input:focus-visible {
outline: 3px solid rgb(11 95 255 / 0.35);
outline-offset: 1px;
border-color: #0b5fff;
}
.input:disabled {
background: #f3f4f6;
color: #6b7280;
cursor: not-allowed;
}
<div class="field">
<label for="email">Email</label>
<input class="input" id="email" type="email" autocomplete="email" required>
</div>
A few details here carry weight.
The label must be connected to the input with for and id, or by wrapping the input in the label. It makes the label clickable, gives the field an accessible name, and gives touch users a bigger target. Placeholder text is not a label. It disappears when typing begins.
Do not remove the focus indicator without replacing it. outline: none makes keyboard navigation impossible to follow.
Use at least 16px font size on inputs. Safari on iPhone zooms the page when focusing an input smaller than 16px, and it does not zoom back. Inheriting a 1rem body size avoids it.
Validation states#
HTML validation attributes (required, type="email", min, pattern) automatically give inputs states that CSS can target:
.input:invalid { border-color: #b42318; }
The problem: :invalid matches as soon as the page loads, so an empty required field is red before the user has touched anything. Two better tools:
/* only after the user has interacted */
.input:user-invalid {
border-color: #b42318;
background: #fef3f2;
}
.input:user-valid { border-color: #067647; }
/* classic trick: only show invalid styling once there is a value */
.input:not(:placeholder-shown):invalid { border-color: #b42318; }
:user-invalid is the cleaner solution, and is supported in all current major browsers. It waits until the user has interacted or tried to submit. The placeholder-shown approach needs a placeholder=" " attribute to work.
Error messages should be real text, connected to the field, and not communicated through color alone:
<input class="input" id="pw" type="password" aria-describedby="pw-error" aria-invalid="true">
<p class="error" id="pw-error">Use at least 12 characters.</p>
.error { color: #b42318; font-size: 0.875rem; }
.error::before { content: "⚠ "; }
Roughly one man in twelve cannot distinguish some reds and greens. An icon or text alongside the color solves it.
Custom checkboxes and radios#
The old technique hid the input and drew a fake one. The modern one is much simpler:
input[type="checkbox"] {
appearance: none;
width: 1.25rem;
height: 1.25rem;
border: 2px solid #6b7280;
border-radius: 4px;
display: inline-grid;
place-content: center;
vertical-align: middle;
cursor: pointer;
}
input[type="checkbox"]::before {
content: "";
width: 0.65rem;
height: 0.65rem;
transform: scale(0);
transition: transform 120ms ease-in-out;
background: #fff;
clip-path: polygon(14% 44%, 0 65%, 50% 100%, 100% 16%, 80% 0%, 43% 62%);
}
input[type="checkbox"]:checked {
background: #0b5fff;
border-color: #0b5fff;
}
input[type="checkbox"]:checked::before { transform: scale(1); }
input[type="checkbox"]:focus-visible { outline: 3px solid rgb(11 95 255 / 0.4); outline-offset: 2px; }
input[type="checkbox"]:disabled { opacity: 0.5; cursor: not-allowed; }
appearance: none removes the native look but keeps the real element, so keyboard, form submission and screen reader behavior all stay intact. The check mark is drawn with ::before and shown when :checked.
If you only want to change the color of a native checkbox, radio or range slider, one property does it:
input { accent-color: #0b5fff; }
Other controls#
select { appearance: none; padding-right: 2rem; background: url("chevron.svg") right 0.75rem center / 1rem no-repeat #fff; }
input[type="file"]::file-selector-button { padding: 0.5rem 1rem; border: 0; border-radius: 6px; background: #0b5fff; color: #fff; margin-right: 1rem; }
textarea { resize: vertical; min-height: 6rem; }
- A
selectdropdown list itself is drawn by the operating system and has limited styling. If you need a fully custom dropdown, build a new component with proper accessibility, or use a library. textarea { resize: vertical }stops users from dragging it wider than the layout.
Laying out a form#
Use grid, and let each field be a small stack:
.form {
display: grid;
gap: 1rem;
max-width: 32rem;
}
@media (min-width: 640px) {
.form .two-up { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; }
}
Keep labels above the inputs on narrow screens. Labels beside inputs save vertical space only on wide layouts, and they break easily when translated into longer text.
Buttons#
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
min-height: 2.75rem; /* about 44px: comfortable tap target */
padding: 0 1.25rem;
border: 0;
border-radius: 8px;
background: #0b5fff;
color: #fff;
font-weight: 600;
cursor: pointer;
}
.btn:hover:not(:disabled) { background: #0847c4; }
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
Use real button elements for actions and links for navigation. A styled div with a click handler has no keyboard support and no semantics.
Common mistakes#
- Controls in a different font because
font: inheritis missing. - Inputs below 16px causing iOS zoom.
- Removing the focus outline.
- Using
:invaliddirectly, which shows errors before the user types. - Placeholder text as labels.
- Communicating errors only by color.
- Small tap targets, below roughly 44 by 44px.
- Hiding the input with
display: nonein a custom checkbox, which removes it from keyboard navigation. - A styled
divinstead of abutton.
Practice#
- Build a sign-up form with name, email, password and a terms checkbox. Style normal, focus, disabled and error states.
- Replace
:invalidwith:user-invalidand test what the user sees at page load and after typing a bad value. - Build a custom checkbox and radio set with
appearance: none. Test them with the keyboard only. - Check the form on a phone-sized viewport. Make sure nothing zooms, labels are readable and every control is at least 44px tall.