On this page
- 1. Set up design tokens
- 2. Normalise the basics
- 3. Style text inputs, selects and textareas together
- 4. Clear, accessible focus states
- 5. Custom checkboxes and radios with accent-color
- 6. A custom select arrow
- 7. Buttons with real states
- 8. Validation styles that don't nag
- 9. Floating labels (done accessibly)
- 10. Dark mode in a few lines
- 11. Layout: keep it scannable
- Accessibility checklist for styled forms
Browser-default forms look dated and inconsistent: each browser draws inputs, selects and checkboxes a little differently. With modern CSS you can make every control look polished and on-brand — while keeping it accessible.
We'll build a small design system for forms, one layer at a time.
1. Set up design tokens
Store colours and sizes in custom properties so the whole form can be re-themed in one place:
:root {
--fc-accent: #645cec;
--fc-accent-dark: #4f46e5;
--fc-text: #0f172a;
--fc-muted: #64748b;
--fc-border: #d4d4e0;
--fc-bg: #ffffff;
--fc-field-bg: #ffffff;
--fc-error: #dc2626;
--fc-radius: 10px;
}
2. Normalise the basics
*, *::before, *::after { box-sizing: border-box; }
input, select, textarea, button {
font: inherit; /* controls don't inherit fonts by default */
color: inherit;
}
.field { display: grid; gap: 6px; margin-bottom: 18px; }
.field label { font-weight: 600; font-size: 14px; color: var(--fc-text); }
font: inherit fixes the most common "why does my input look different?" problem — form controls use the system font unless told otherwise.
3. Style text inputs, selects and textareas together
.field :is(input, select, textarea) {
width: 100%;
padding: 12px 14px;
font-size: 16px;
background: var(--fc-field-bg);
border: 1px solid var(--fc-border);
border-radius: var(--fc-radius);
transition: border-color .15s, box-shadow .15s;
}
.field textarea { min-height: 120px; resize: vertical; }
.field :is(input, select, textarea)::placeholder { color: #94a3b8; }
.field :is(input, select, textarea):hover { border-color: #b4b4c8; }
4. Clear, accessible focus states
Never remove focus outlines without a replacement — keyboard users depend on them. :focus-visible shows the ring for keyboard users without flashing it on every mouse click for buttons.
.field :is(input, select, textarea):focus {
outline: none;
border-color: var(--fc-accent);
box-shadow: 0 0 0 4px color-mix(in srgb, var(--fc-accent) 20%, transparent);
}
button:focus-visible {
outline: 3px solid var(--fc-accent);
outline-offset: 3px;
}
5. Custom checkboxes and radios with accent-color
The quickest way to brand checkboxes, radios, range sliders and progress bars is one line:
form { accent-color: var(--fc-accent); }
Want full control? Use appearance: none and draw your own — the input remains a real, accessible checkbox:
input[type="checkbox"] {
appearance: none;
width: 20px;
height: 20px;
margin: 0;
display: inline-grid;
place-content: center;
border: 1.5px solid var(--fc-border);
border-radius: 6px;
background: #fff;
cursor: pointer;
}
input[type="checkbox"]::after {
content: "";
width: 10px;
height: 10px;
transform: scale(0);
transition: transform .12s ease-in-out;
clip-path: polygon(14% 44%, 0 65%, 50% 100%, 100% 16%, 80% 0%, 43% 62%);
background: #fff;
}
input[type="checkbox"]:checked {
background: var(--fc-accent);
border-color: var(--fc-accent);
}
input[type="checkbox"]:checked::after { transform: scale(1); }
input[type="checkbox"]:focus-visible {
outline: 3px solid color-mix(in srgb, var(--fc-accent) 40%, transparent);
outline-offset: 2px;
}
6. A custom select arrow
.field select {
appearance: none;
padding-right: 40px;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8' fill='none' stroke='%2364748b' stroke-width='2'%3E%3Cpath d='M1 1l5 5 5-5'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 14px center;
cursor: pointer;
}
7. Buttons with real states
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
width: 100%;
min-height: 50px;
padding: 0 22px;
font-weight: 700;
color: #fff;
background: var(--fc-accent);
border: 0;
border-radius: var(--fc-radius);
cursor: pointer;
transition: background .15s, transform .1s;
}
.btn:hover { background: var(--fc-accent-dark); }
.btn:active { transform: translateY(1px); }
.btn:disabled { opacity: .55; cursor: not-allowed; }
8. Validation styles that don't nag
:invalid matches empty required fields as soon as the page loads, which paints the whole form red before anyone has typed. :user-invalid waits until the user has interacted with the field:
.field :user-invalid {
border-color: var(--fc-error);
}
.field :user-invalid:focus {
box-shadow: 0 0 0 4px color-mix(in srgb, var(--fc-error) 18%, transparent);
}
.field .error { display: none; font-size: 13px; color: var(--fc-error); }
.field :user-invalid ~ .error { display: block; }
Don't rely on colour alone — pair red borders with a text message so colour-blind users get the information too.
9. Floating labels (done accessibly)
Floating labels save space and still keep a real label. The trick is :placeholder-shown with a single-space placeholder:
<div class="float">
<input id="femail" name="email" type="email" placeholder=" " required>
<label for="femail">Email address</label>
</div>
.float { position: relative; }
.float input { padding: 22px 14px 8px; }
.float label {
position: absolute;
left: 15px;
top: 16px;
color: var(--fc-muted);
pointer-events: none;
transition: transform .15s, font-size .15s, color .15s;
transform-origin: left top;
}
.float input:focus + label,
.float input:not(:placeholder-shown) + label {
transform: translateY(-9px);
font-size: 12px;
color: var(--fc-accent);
}
10. Dark mode in a few lines
Because everything uses tokens, dark mode is just new values:
@media (prefers-color-scheme: dark) {
:root {
--fc-text: #f1f5f9;
--fc-muted: #94a3b8;
--fc-border: #334155;
--fc-bg: #0f172a;
--fc-field-bg: #1e293b;
}
form { color-scheme: dark; } /* dark native pickers and scrollbars */
}
11. Layout: keep it scannable
- One column is fastest to complete. Pair fields only when they belong together (first/last name, city/postcode).
- Labels above fields work on every screen size.
- Match field width to expected input — a postcode box doesn't need to be as wide as an address.
- Generous spacing between fields (16–24px) helps people see where one ends and the next begins.
Responsive layout is covered in depth in How to Create a Responsive HTML Form.
Accessibility checklist for styled forms
- Text and borders meet contrast guidelines (4.5:1 for text, 3:1 for input borders).
- Focus is always visible.
- Custom controls are still real
<input>elements. - Errors use text as well as colour.
- Tap targets are at least 44px tall.
Every one of our free HTML form templates uses these techniques, and they all post straight to Formclick — so they look good and deliver submissions to your inbox.



