Formclick

How to Style HTML Forms Using CSS

Turn default browser forms into polished, on-brand forms with modern CSS — consistent inputs, focus rings, custom checkboxes and selects, error states and dark mode.

FFormclick Team4 min read
A software developer engaged in coding on dual monitors in a modern office setting.
Photo by Ofspace LLC, Culture on Pexels
On this page
  1. 1. Set up design tokens
  2. 2. Normalise the basics
  3. 3. Style text inputs, selects and textareas together
  4. 4. Clear, accessible focus states
  5. 5. Custom checkboxes and radios with accent-color
  6. 6. A custom select arrow
  7. 7. Buttons with real states
  8. 8. Validation styles that don't nag
  9. 9. Floating labels (done accessibly)
  10. 10. Dark mode in a few lines
  11. 11. Layout: keep it scannable
  12. 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.

Found this useful? Share it.

All articles