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.
input, button, textarea, select {
font: inherit;
color: inherit;
}
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.
input[type="checkbox"],
input[type="radio"],
select {
appearance: none; /* strip the platform widget */
}
: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.
.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
<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>
.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; }
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.
.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.
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-heighton its container. - Group related fields in a
fieldsetand style thelegend; do not replace them with a heading.
.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, andcursor: not-allowed.:user-invalid— after interaction only, with a text message as well as the colour.:checkedand:indeterminatefor boxes.::placeholder— and remember it is a hint, never the label.