Overriding Browser Form Control Defaults in a Base Layer
Form controls are the one place where the browser’s stylesheet is genuinely opinionated: it sets fonts that do not inherit, draws widgets CSS cannot reach, and varies noticeably between engines. Getting them into a layered architecture cleanly means knowing which declarations are corrections and which are decisions, because they belong in different layers. This procedure extends cascade origins and user agent styles under the specificity management reference.
Prerequisites
You should know that every author declaration outranks every normal user-agent declaration, so none of the rules below need !important. The stack:
/* WHY: `reset` holds neutral corrections; `base` holds the house style that
builds on them. Splitting them means a consumer can adopt your reset
without inheriting your opinions about how a checkbox should look. */
@layer reset, base, theme, components, utilities;Tooling: DevTools with Show user agent styles enabled, plus a keyboard and, ideally, a screen reader for the verification step.
Step 1: Restore inherited typography in reset
@layer reset {
/* WHY: the UA sets `font` directly on each control, which halts
inheritance at that element. Naming the controls explicitly is the only
way an author declaration exists for the browser's to lose to. */
button, input, select, textarea, optgroup {
font: inherit;
color: inherit;
letter-spacing: inherit;
}
/* WHY: Safari and Firefox disagree about textarea's default line-height
and about whether it is resizable in both axes. */
textarea { resize: vertical; }
}What this does: makes every control adopt the surrounding typography. This one block resolves the majority of “the button font is wrong” reports without touching a single component.
Step 2: Decide where appearance: none is worth it
Removing a native widget is a commitment to redraw it — including every state the platform was drawing for free.
@layer base {
/* WHY: `appearance: none` on a checkbox removes the box, the tick, the
hover treatment, the checked state AND the high-contrast rendering.
Doing it in `base` (not `reset`) marks it as a decision rather than a
correction, so a consumer of the reset alone is unaffected. */
input[type="checkbox"], input[type="radio"] {
appearance: none;
inline-size: 1rem;
block-size: 1rem;
border: 2px solid var(--color-border);
background: var(--color-surface);
display: inline-grid;
place-content: center;
}
input[type="radio"] { border-radius: var(--radius-full); }
}What this does: replaces the platform widget with a box you control. Everything the platform used to draw is now your responsibility — which Step 3 delivers.
Step 3: Re-add every affordance you removed
@layer base {
/* WHY: the tick. Drawn with clip-path rather than a glyph so it scales
with the box and needs no font. */
input[type="checkbox"]::before {
content: "";
inline-size: 0.55rem;
block-size: 0.55rem;
transform: scale(0);
transition: transform var(--transition-fast);
box-shadow: inset 1rem 1rem var(--color-primary);
clip-path: polygon(14% 44%, 0 65%, 50% 100%, 100% 16%, 80% 0%, 43% 62%);
}
input[type="checkbox"]:checked::before { transform: scale(1); }
/* WHY: focus visibility was drawn by the platform and vanished with
appearance:none. Restoring it is not optional. */
input[type="checkbox"]:focus-visible,
input[type="radio"]:focus-visible {
outline: 2px solid var(--color-focus);
outline-offset: 2px;
}
/* WHY: the disabled treatment too — a control that looks enabled but
rejects input is worse than an unstyled one. */
input:disabled, button:disabled {
opacity: 0.55;
cursor: not-allowed;
}
/* WHY: forced-colors mode discards author colours, so the tick must be
drawn with a system colour or it disappears entirely. */
@media (forced-colors: active) {
input[type="checkbox"]::before { box-shadow: inset 1rem 1rem CanvasText; }
}
}What this does: restores the state affordances the platform provided. The forced-colors block is the one most often forgotten, and its absence produces a checkbox that can be checked but never appears checked.
Step 4: Keep the layer boundaries honest
/* reset → corrections nobody would disagree with (font: inherit)
base → house style for every control (appearance, box, tick)
theme → tokens those rules consume (colours, radii, focus colour)
components → per-component specialisation (.btn, .field--inline)
utilities → one-off adjustments (.u-w-full) */
@layer components {
/* WHY: a component specialises the base control instead of re-declaring
it, so a change to the base checkbox propagates everywhere. */
.field--compact input[type="checkbox"] {
inline-size: 0.875rem;
block-size: 0.875rem;
}
}What this does: keeps a single definition of each control and a single place to change it.
Verification
- Tab through a form with the keyboard. Every control must show a visible focus indicator, and it must be visible against both the light and dark surface.
- Check the checked state in forced-colors emulation: Rendering → Emulate CSS media feature forced-colors → active. The tick must still be visible.
- Inspect a control and confirm the UA declarations appear struck through beneath your
resetrule, with no!importantanywhere in the chain. - Verify semantics survived —
appearance: nonechanges rendering only, never the accessibility tree:
// A styled checkbox must still report as a checkbox with a checked state.
const cb = document.querySelector('input[type="checkbox"]');
console.log(cb.type, cb.checked, cb.matches(':checked'));- Test in Safari specifically. Its UA stylesheet differs most from Chromium’s around controls, and
-webkit-appearancelegacy behaviour still surfaces on older versions.
Troubleshooting
The checkbox is invisible after appearance: none.
: The replacement box has no border or background yet. appearance: none removes the entire native rendering in one step, so the box, the tick and the checked state all have to be re-declared before anything shows.
The focus ring disappeared on one control only.
: A component rule set outline: none for that control. Search for it; the fix is to restyle the ring in theme, never to remove it. A focus indicator is a WCAG requirement, not a stylistic preference.
Placeholder text ignores the reset.
: Placeholders are styled through the ::placeholder pseudo-element, not through the control’s own colour. Add ::placeholder { color: var(--color-text-muted); } in base.
A <select> still looks native inside its own box.
: The dropdown list is rendered by the operating system and is outside the document. You can style the closed control and nothing else. If the open list must match the design system, replace the element with a custom listbox that implements the ARIA combobox pattern.
Controls look right in Chrome and cramped in Safari.
: Safari applies different intrinsic sizing to controls. Set explicit inline-size/block-size (or padding plus line-height) rather than relying on the intrinsic default, and re-check in both engines.
Complete working example
/* ============================================================
Layered form-control normalisation — copy as a starting point
============================================================ */
@layer reset, base, theme, components, utilities;
@layer theme {
:root {
--color-border: #d4c89a;
--color-surface: #fffdf5;
--color-primary: #5a6830;
--color-focus: #c49a2a;
--color-text-muted: #6b5e3e;
--radius-sm: 0.25rem;
--radius-full: 9999px;
--transition-fast: 0.15s ease;
}
}
@layer reset {
/* WHY: corrections only — safe for any consumer to adopt wholesale. */
button, input, select, textarea, optgroup {
font: inherit;
color: inherit;
letter-spacing: inherit;
}
textarea { resize: vertical; }
button { cursor: pointer; }
}
@layer base {
/* WHY: decisions — this is where the design system's opinion starts. */
input[type="checkbox"],
input[type="radio"] {
appearance: none;
inline-size: 1rem;
block-size: 1rem;
margin: 0;
border: 2px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-surface);
display: inline-grid;
place-content: center;
transition: border-color var(--transition-fast);
}
input[type="radio"] { border-radius: var(--radius-full); }
input[type="checkbox"]::before,
input[type="radio"]::before {
content: "";
inline-size: 0.55rem;
block-size: 0.55rem;
transform: scale(0);
transition: transform var(--transition-fast);
box-shadow: inset 1rem 1rem var(--color-primary);
}
input[type="checkbox"]::before {
clip-path: polygon(14% 44%, 0 65%, 50% 100%, 100% 16%, 80% 0%, 43% 62%);
}
input[type="radio"]::before { border-radius: var(--radius-full); }
input:checked::before { transform: scale(1); }
input:checked { border-color: var(--color-primary); }
/* WHY: restored affordances — focus, disabled, placeholder. */
input:focus-visible,
button:focus-visible,
select:focus-visible,
textarea:focus-visible {
outline: 2px solid var(--color-focus);
outline-offset: 2px;
}
input:disabled, button:disabled, select:disabled {
opacity: 0.55;
cursor: not-allowed;
}
::placeholder { color: var(--color-text-muted); opacity: 1; }
/* WHY: the indicator must survive the system palette substitution. */
@media (forced-colors: active) {
input:checked::before { box-shadow: inset 1rem 1rem CanvasText; }
input { border-color: CanvasText; }
}
/* WHY: respect a reader who has asked for less motion. */
@media (prefers-reduced-motion: reduce) {
input[type="checkbox"], input[type="radio"],
input[type="checkbox"]::before, input[type="radio"]::before {
transition: none;
}
}
}Frequently Asked Questions
Why does font: inherit have to be repeated for every control?
Because the user-agent stylesheet sets font on button, input, select and textarea individually, and an explicit declaration on an element stops inheritance from reaching it. Declaring a font on body therefore never arrives. Naming the controls gives the cascade an author declaration on the same element, which wins on origin, and inherit then reconnects the control to its ancestors.
Should appearance: none go in the reset or base layer?
Base. A reset should contain corrections that any consumer would accept — font: inherit, margin: 0, box-sizing. Removing the native widget is different in kind: it obliges you to redraw the control’s box, tick, focus ring and disabled state, which is a design decision. Keeping it out of the reset means a team can take your corrections without inheriting your checkbox.
Which parts of a native control can CSS not style?
The parts the platform draws outside the document: a select’s option list, the native date and colour pickers, the file chooser dialog and the browser’s own validation bubble. No origin, layer or specificity reaches them, because they are not elements. Style the closed control and accept the platform rendering, or replace the element with a custom widget that implements the equivalent ARIA pattern.
Related
- Cascade Origins, User Agent and User Styles — why none of these rules need
!important - How User Agent Styles Interact With Cascade Layers — the audit that finds a missing correction
- Handling User Stylesheets and Forced Colors With Layers — keeping these controls legible under reader overrides
- Normalization and Reset in Layers — where the correction/decision boundary sits
Up: Cascade Origins & User Agent Styles → Specificity Management & Conflict Resolution