Skip to main content
Being Idea Innovations
Custom Checkbox and Radio Styling in Pure CSS (No Library Needed)
Back to Blog

Custom Checkbox and Radio Styling in Pure CSS (No Library Needed)

29 November 20185 min readHTML
Share:

Styling checkboxes and radio buttons used to require a plugin. iCheck, icheck-bootstrap and their contemporaries existed because browsers gave you almost no control over form control appearance.

That is no longer true. Modern CSS styles these natively, with better accessibility and no dependency. If you are still loading a library for this, you can delete it.

The one-line version: accent-color

If all you want is your brand colour on otherwise native controls, this is the entire solution:

input[type="checkbox"],
input[type="radio"] {
  accent-color: #2563EB;
  width: 1.15em;
  height: 1.15em;
}

Or set it once for the whole document:

:root { accent-color: #2563EB; }

Supported in every current browser. You keep the platform's native control — which means correct keyboard behaviour, correct high-contrast mode rendering, correct touch targets on mobile, and correct appearance in the user's dark mode — and you change the colour. For most projects this is where the work should stop.

The browser also picks a contrasting checkmark colour automatically, so a light accent gets a dark tick without you specifying anything.

Full control with appearance: none

When the design genuinely requires a custom shape, remove the native rendering and draw your own:

.check {
  display: inline-flex;
  align-items: center;
  gap: 0.6rem;
  cursor: pointer;
  line-height: 1.4;
}

.check input {
  appearance: none;
  -webkit-appearance: none;

  /* Keep the real input in the layout — do not display:none it. */
  flex: none;
  width: 1.25rem;
  height: 1.25rem;
  margin: 0;

  border: 2px solid #94A3B8;
  border-radius: 0.3rem;
  background: #fff;
  cursor: pointer;

  display: grid;
  place-content: center;
  transition: background-color .15s, border-color .15s;
}

/* Radios differ only in being round. */
.check input[type="radio"] { border-radius: 50%; }

/* The tick, drawn with a pseudo-element and revealed on :checked. */
.check input::before {
  content: "";
  width: 0.7rem;
  height: 0.7rem;
  transform: scale(0);
  transition: transform .12s ease-in-out;
  box-shadow: inset 1em 1em #fff;

  /* A checkmark polygon — no icon font, no SVG file. */
  clip-path: polygon(14% 44%, 0 65%, 50% 100%, 100% 16%, 80% 0%, 43% 62%);
}

.check input[type="radio"]::before {
  border-radius: 50%;
  clip-path: none;
  width: 0.6rem;
  height: 0.6rem;
}

.check input:checked {
  background: #2563EB;
  border-color: #2563EB;
}

.check input:checked::before { transform: scale(1); }

/* Indeterminate — set from JavaScript, common for "select all" headers. */
.check input:indeterminate {
  background: #2563EB;
  border-color: #2563EB;
}
.check input:indeterminate::before {
  transform: scale(1);
  clip-path: polygon(15% 42%, 85% 42%, 85% 58%, 15% 58%);
}

/* Keyboard focus only — no ring on mouse click. */
.check input:focus-visible {
  outline: 2px solid #2563EB;
  outline-offset: 2px;
}

.check input:disabled {
  border-color: #CBD5E1;
  background: #F1F5F9;
  cursor: not-allowed;
}
.check:has(input:disabled) { color: #94A3B8; cursor: not-allowed; }

The states you need to handle

The six visual states a custom checkbox must handle: unchecked, checked, indeterminate, keyboard focus, disabled and disabled checked unchecked checked indeterminate focus-visible disabled disabled + checked Every one of these needs a distinct appearance. Skipping focus-visible is the most common omission — and the one that breaks keyboard use.
The six states a custom control has to cover. :indeterminate can only be set from JavaScript, never from HTML.

The markup

<label class="check">
  <input type="checkbox" name="terms" />
  <span>I accept the terms</span>
</label>

<label class="check">
  <input type="radio" name="plan" value="monthly" />
  <span>Monthly</span>
</label>
<label class="check">
  <input type="radio" name="plan" value="annual" />
  <span>Annual</span>
</label>

Wrapping the input inside the <label> associates them implicitly, so no matching id and for attributes are required and they cannot drift out of sync. Either approach is valid; this one has fewer moving parts.

Group related radios in a fieldset so screen readers announce what the choice is about:

<fieldset>
  <legend>Billing period</legend>
  <!-- radios -->
</fieldset>

Accessibility mistakes to avoid

This is where the old plugin-based approaches usually went wrong, and where hand-rolled CSS still does.

  • Never display: none or visibility: hidden the input. That removes it from the accessibility tree and from the tab order entirely, so keyboard users cannot reach it and screen readers do not announce it. Use appearance: none and keep the element in the layout — that is precisely what it is for.
  • Never remove the focus ring without replacing it. outline: none on its own makes a form unusable by keyboard. Use :focus-visible so the indicator shows for keyboard users without appearing on mouse clicks.
  • Do not fake a control with a <div>. A div with a click handler has no implicit role, no keyboard activation, no form participation, and will not submit with the form.
  • Keep the touch target large enough. A 20px box is fine visually, but the clickable area should reach roughly 44×44px. Padding on the wrapping label achieves this without changing the visual size.
  • Test in forced-colors mode. Windows High Contrast overrides your background colours; without a border the control can vanish. Add a @media (forced-colors: active) block that restores a visible outline.
@media (forced-colors: active) {
  .check input { border-color: CanvasText; }
  .check input:checked { background: Highlight; }
}

The indeterminate state

Useful for a "select all" header when only some children are selected. It cannot be set in HTML — only through the DOM:

const all   = document.querySelector('#select-all');
const items = document.querySelectorAll('.item-check');

function sync() {
  const checked = [...items].filter((i) => i.checked).length;
  all.checked = checked === items.length;
  all.indeterminate = checked > 0 && checked < items.length;
}

all.addEventListener('change', () => {
  items.forEach((i) => { i.checked = all.checked; });
});

items.forEach((i) => i.addEventListener('change', sync));
sync();

Should you still use iCheck?

No. The original iCheck plugin depends on jQuery and has been unmaintained for years; icheck-bootstrap is CSS-only but ties you to a fixed set of colour classes and to Bootstrap's markup conventions.

Everything either of them offered is now a handful of CSS rules with no dependency, no jQuery, no extra HTTP request, and better accessibility — because you are styling the real control rather than hiding it and drawing a replacement.

If you only need brand colours, accent-color alone is a single line and keeps every native behaviour intact. Reach for appearance: none only when the design demands a shape the platform will not give you.

Share:

Written by

Amit Verma

Founder / Senior Software Engineer

Amit leads engineering at Being Idea. With 15+ years building scalable software products across global markets, he drives architecture decisions and engineering culture across every engagement.

More articles by Amit

Want to talk tech?

We ship software that scales. Let's work together.

No long-term contracts
Senior engineers only
US · AU · NZ timezone coverage
14-day trial on retainers