Under-Engineered Custom Radio Buttons and Checkboxen

I keep seeing overly-complex controls with additional elements as style hooks, scripting to make up for non-semantic replacements, images that need to be downloaded, and so on.

This is silly. Here are some really simple styles to make radio buttons and checkboxes look unlike native controls (which seems to be the main goal from these over-engineered attempts).

The code in here is not perfect, but it is simple, semantic, and accessible. It does not require any third-party libraries, it does not use images, and no scripting is needed. It is pure HTML and CSS. Barring the <div>s (for layout within the page only), the HTML cannot get much leaner. I have embedded a CodePen so you can play around with it. Or you can visit it directly on CodePen.

See the Pen Under-Engineered Custom Radio Buttons and Checkboxen by Adrian Roselli (@aardrian) on CodePen.

Some Detail

There are a few bits in here that I think warrant some explanation beyond just throwing CSS and HTML at you. After all, when making something without jacking up the HTML, I think it is important to note how you did not jack it up.

Some HTML Notes

The styles rely on the following very simple mark-up: <input>, then <label>. That is it. If you use explicit labeling (by wrapping the entire control and its text inside a <label>) then you will need to re-write the CSS. That is on you.

<input type="radio" name="spam" id="spamY" checked>
<label for="spamY">
  Please send me all the spam.
</label>

What is great about this approach is that if your CSS fails for some reason, you still have a fully functional accessible field.

Please note that there is no ARIA, whether by converting a <button> element via role abuse or relying on any of the aria-label, aria-labelledby, nor aria-describedby variations.

Some CSS Notes

I do not remove the control from the page. I do not give it a display: none property since that hides it from assistive technology. Instead I rely on a CSS technique to visually hide the control that does not rely on the kinds of hacks that mess up RTL content (or content translated to RTL). If you see me answer questions on Stack Overflow about off-screen techniques, this is the style block I typically use, but with a visually-hidden class name as the selector:

input[type=radio],
input[type=checkbox] {
  position: absolute;
  top: auto;
  overflow: hidden;
  clip: rect(1px, 1px, 1px, 1px);
  width: 1px;
  height: 1px;
  white-space: nowrap;
}

Having visually hidden the controls, I can now create some fake visible replacement controls using CSS:

input[type=radio] + label::before,
input[type=checkbox] + label::before {
  content: '';
  background: #fff;
  border: .1em solid rgba(0, 0, 0, .75);
  background-color: rgba(255, 255, 255, .8);
  display: block;
  box-sizing: border-box;
  float: left;
  width: 1em;
  height: 1em;
  margin-left: -1.5em;
  margin-top: .15em;
  vertical-align: top;
  cursor: pointer;
  text-align: center;
  transition: all .1s ease-out;
}

input[type=radio] + label::before {
  border-radius: 100%;
}

Note the transition. That helps animate the control just enough that it feels like an action happened instead of just an awkward swap. Note that the radio button is made into a circle, but otherwise is the same code. All sizing is based on ems so this should scale to whatever font size you want. You can test it in my example by changing body { font-size: 100%; } to whatever value you want.

The radio button is easy. It just gets a fill and shadow. It animates easily enough, and does not get announced by screen readers.

input[type=radio]:checked + label::before {
  background-color: #00f;
  box-shadow: inset 0 0 0 .15em rgba(255, 255, 255, .95);
}

The checkbox could be as easy, but the shape of square versus circle is not enough in my opinion to denote the difference between the two types of controls. Originally I added a checkmark character, partly because I was using ::after pseudo-element to add the text (disabled) for disabled controls in other contexts. One context was Windows High Contrast Mode, but I address it differently below. So I changed my tactics and now use a pseudo-element to make the checkmark.

input[type=checkbox] + label::after {
  display: block;
  content: '';
  position: absolute;
  top: .5em;
  left: .5em;
  width: 1em;
  height: 1em;
  transform: rotate(0deg);
  transition: all .1s ease-out;
}

input[type=checkbox]:checked + label::after {
  top: .4em;
  left: .7em;
  width: .4em;
  height: .8em;
  border-right: .25em solid #00f;
  border-bottom: .25em solid #00f;
  transform: rotate(45deg);
}

There is so much code because I am changing its size, position, and rotation in order to make it more obvious in the transition. You can, of course, change that whatever you prefer.

Right-to-Left Support

The generated replacement controls are floated, and padding is given to the text to fit them. Changing them to work in the opposite flow is a matter of swapping and resetting values.

*[dir="rtl"] input[type=radio] + label,
*[dir="rtl"] input[type=checkbox] + label {
  padding-left: .5em;
  padding-right: 2em;
}

*[dir="rtl"] input[type=radio] + label::before,
*[dir="rtl"] input[type=checkbox] + label::before {
  float: right;
  margin-left: 0;
  margin-right: -1.5em;
}

*[dir="rtl"] input[type=checkbox] + label::after {
  left: auto;
  right: .5em;
}

*[dir="rtl"] input[type=checkbox]:checked + label::after {
  left: auto;
  right: .7em;
}
The fields in Hebrew.
As captured in Internet Explorer 11

Dark Mode

If you choose to support a dark mode, make sure to adjust all your text, border, outline, and shadow colors to have sufficient contrast. I stick with the blue theme but go with a much brighter color against a dark background. Where possible, only change color values, such as with border-right-color instead of redefining all the styles for border-right. This can help ensure the styles do not fall out of sync across different media or feature queries.

@media screen and (prefers-color-scheme: dark) {
[…]
  input[type=radio]:focus + label,
  input[type=checkbox]:focus + label {
    color: #0ff;
    outline: 1px dotted #0ff;
  }
  input[type=radio] + label::before,
  input[type=checkbox] + label::before {
    background: #000;
    border-color: rgba(255, 255, 255, .75);
    background-color: rgba(0, 0, 0, .8);
  }
  input[type=radio]:disabled + label::before,
  input[type=checkbox]:disabled + label::before {
    border-color: rgba(255, 255, 255, .1);
    background-color: rgba(255, 255, 255, .1);
  }
  input[type=radio]:disabled + label,
  input[type=checkbox]:disabled + label {
    color: #555;
  }
  input[type=radio]:checked + label::before {
    background-color: #0ff;
    box-shadow: inset 0 0 0 .15em rgba(0, 0, 0, .95);
  }
  input[type=checkbox]:checked + label::after {
    border-right-color: #0ff;
    border-bottom-color: #0ff;
  }
  input[type=checkbox]:checked:disabled + label::after {
    border-right-color: #555;
    border-bottom-color: #555;
  }
}
The fields on a dark background with lighter text and borders / lines.
Dark mode.

Print

Sometimes people print forms as a record. Consider if you want to undo styles and revert to the native controls, if you want to make everything black and white (to maybe save users’ color ink), or if you want to preserve your controls as-is. I opted to keep them as-is.

@media print {
  input[type=radio] + label::before,
  input[type=radio] + label::after,
  input[type=radio]:disabled + label,
  input[type=checkbox] + label::before,
  input[type=checkbox] + label::after,
  input[type=checkbox]:disabled + label {
    -webkit-print-color-adjust: exact;
    color-adjust: exact;
  }
}

Windows High Contrast Mode

As I was reminded in a comment, I forgot to add styles for Windows High Contrast Mode (I have a post on WHCM for more information on the mode).

Changing the checkbox to work is easy — just change the border color:

@media screen and (-ms-high-contrast: active) {
  input[type=checkbox]:checked + label::after {
    border-right-color: windowText;
    border-bottom-color: windowText;
  }

The radio button is trickier since above I am using a box shadow to create the dot. As that will not work in WHCM (and -ms-high-contrast-adjust: none; does not do what I need), I re-create it as a pseudo-element using almost the same code as I initially used for the checkbox.

  input[type=radio] + label::after {
    display: block;
    content: '';
    position: absolute;
    top: 1.05em;
    left: .9em;
    width: .2em;
    height: .2em;
    border: none;
    border-radius: 50%;
    background-color: transparent;
    transition: all .1s ease-out;
  }
  input[type=radio]:checked + label::after {
    top: .85em;
    left: .7em;
    width: .6em;
    height: .6em;
    background-color: windowText;
  }
  *[dir="rtl"] input[type=radio] + label::after {
    left: auto;
    right: .9em;
  }
  *[dir="rtl"] input[type=radio]:checked + label::after {
    left: auto;
    right: .7em;
  }

And do not forget the disabled text:

  input[type=checkbox]:disabled + label, input[type=checkbox]:disabled:checked + label::after, input[type=radio]:disabled + label {
    color: grayText;
    cursor: not-allowed;
    border-right-color: grayText;
    border-bottom-color: grayText;
  }
}

I also have a screen shot:

The default WHCM setting, with white text on black.
From Microsoft Edge.

Variations

The colors I chose are arbitrary. You can obviously change them. You can also play around with the CSS generated content. For example, in this (less accessible) example (embedded below) I replaced the circle in the radio buttons with a peach (🍑), and checkmark in the checkbox with an aubergine (yep, I called 🍆 that).

See the Pen Emoji Buttons! by Adrian Roselli (@aardrian) on CodePen.

Please be aware how this impacts a screen reader. As I navigate through the form with the emoji using NVDA and Firefox, this is what I hear (and NVDA is correct to do it this way):

Peach please send me all the spam radio button checked one of two.

Clickable eggplant I have read your terms and they make no sense.

I kinda like putting screen reader output in <blockquote>s when it is talking nonsense that is my fault.

Update

There is always more than one way to skin a form. From the Twitters, here is a variation using SVG that is hidden from screen readers:

Update: July 28, 2017

Heydon Pickering offered a similar but somewhat different version of this approach in 2014 in the article Replacing Radio Buttons Without Replacing Radio Buttons. I just discovered it today. Oops.

Update: February 13, 2019

If you are using your SVG images for the markers, check out Scott O’Hara’s post Quick Note on high contrast mode for potential gotchas that may apply if the controls are disabled.

Update: March 25, 2019

I leaned on techniques I outlined above and made a new post to style the oft-incorrectly-used toggle: Under-Engineered Toggles

It includes considerations I did not account for here, such as reduced motion (was not necessary), supporting dark mode (did not exist), and left-to-right languages (oops). At the very least, you can take some of those details and apply them to this code.

Update: August 16, 2019

Jen Simmons has also created a CSS-only, JavaScript-free checkbox. Her approach relies on -webkit-appearance: none;, a pre-fixed / non-standard property which I do not use. Her example does not include disabled styles but you can grab CSS selectors from above and make your own. She also made radio buttons, though they have some layout issues in Chrome unrelated to the radio styles.

14 Comments

Reply

That’s refreshing! ;-)

For what it is worth, I prefer to not qualify the attribute selectors as it increases specificity for no good reason. In other words, I prefer to use [type=checkbox] rather than input[type=checkbox].

In response to Thierry Koblentz. Reply

A valid point, but in a larger collection of form element styles having the input in the selector has proven handy. Whatever selector specificity gets the job done works for me, so by all means if you can do it with a simpler selector then readers should give it a go.

Reply

white-space: nowrap; /* https://medium.com/@jessebeach/beware-smushed-off-screen-accessible-text-5952a4c2cbfe */
This is true for elements thats have content. Inputs are content-empty.

In response to pj. Reply

Note that in my explanation of the style I explain that this is my general accessible hiding style. As such, that declaration would be in there anyway. Consider it a bonus.

Reply

neat technique. but a label wrapping the form input is called an implicit label, not explicit. Explicit is when you link via for/id.

In response to Doug. Reply

Per the WAI tutorials, you are correct:

Whenever possible, use the label element to associate text with form elements explicitly. The for attribute of the label must exactly match the id of the form control.

Per the HTML5 spec, it is less clear:

The form attribute is used to explicitly associate the
label element with its form owner.

The following example shows three form controls each with a label, two of which have small text showing the right format for users to use.

<p><label>Full name: <input name=fn> <small>Format: First Last</small></label></p>
<p><label>Age: <input name=age type=number min=0></label></p>
<p><label>Post code: <input name=pc> <small>Format: AB12 3CD</small></label></p>

In short, I need to do some more digging to figure out the discrepancy (which does not mean I am right, I may just be mis-reading it). My take above dates back to 2014.

In response to Adrian Roselli. Reply

The @for attribute links a form label element to a form control element (via ID). The @form attribute links a form control element (including LABEL) to a particular FORM element (again, via ID).

Reply

Backgrounds disappear in high contrast mode. Accessibility is more than blind people ;)

Ramón Corominas; . Permalink
In response to Ramón Corominas. Reply

Ugh. And I know better. Conveniently, I can steal the media queries I need from this handy post and update this example. Will update here when I get to it.

In response to Ramón Corominas. Reply

Ramón, I have updated the code with a WHCM media query and appropriate styles.

Reply

Hmm – none of these examples actually update the ‘checked’ property on the radio button that you select. It always leaves the first radio button as the checked one, which means the correct data won’t be sent to the server. Am I safe to assume some javascript is required to update the checked status of the hidden inputs?

Scott; . Permalink
In response to Scott. Reply

Scott, no JavaScript is needed. I have updated the pen with a value for each radio button and added method="get" so you can see the value come through in the query string if you submit the form in the debug view. As I navigate the radio buttons with a screen reader, it announces each as checked (when it is checked). Note that if you just inspect the nodes you will not see the checked attribute change between them.

Reply

Thank you for such well written write up on how to style radio buttons and checkboxes.

One minor quibble though, and I hope this is not perceived as being prudish, but the use of the Butt Sex emoji pairing to illustrate the use of emoji feels kind of puerile and detracts from overall tone and tenor of the post.

thanks again!

JGrogan; . Permalink
In response to JGrogan. Reply

A fair criticism; probably why this post will never get coverage on the local news.

Leave a Reply to Thierry Koblentz Cancel response

You may use these HTML tags and attributes: <a href="" title=""> <abbr title=""> <acronym title=""> <b> <blockquote cite=""> <cite> <code> <del datetime=""> <em> <i> <q cite=""> <s> <strike> <strong>

This site uses Akismet to reduce spam. Learn how your comment data is processed.