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, 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.

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=checkbox] {
  position: absolute;
  top: auto;
  overflow: hidden;
  clip: rect(1px 1px 1px 1px); /* IE 6/7 */
  clip: rect(1px, 1px, 1px, 1px);
  width: 1px;
  height: 1px;
  white-space: nowrap; /* https://medium.com/@jessebeach/beware-smushed-off-screen-accessible-text-5952a4c2cbfe */

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. Instead I looked for a checkmark character to use. Unfortunately, any character I use will be announced by a screen reader so I have to be careful to choose one that is not confusing.

input[type=checkbox] + label:before {
  /*content: '\00d7';*/
  /*content: '\002718';*/
  /*content: '\002715';*/
  /*content: '\0025a0';*/
  content: '\002713';
  font-weight: bold;
  text-align: center;
  line-height: 1.15;
  color: rgba(0, 0, 255, 0);

input[type=checkbox]:checked + label:before {
  color: rgba(0, 0, 255, 1);
  line-height: .75;
  text-shadow: .05em 0 0 rgba(0, 0, 255, 1), -.05em 0 0 rgba(0, 0, 255, 1);

I commented out some examples that you can try. The one I use in the demo is a checkmark, and is stated as such when spoken. It is a bit redundant but acceptable. Also be careful to test across browsers. The character for the heavy checkmark (✔) was rendering fine in all browsers but Edge, where it displayed it as a green checkmark. This meant I could not make it transparent for my animated check effect and it also messed with my disabled style. Unfortunately, using the simpler character meant I had to use text-shadow to add weight to the standard checkmark character (✓).


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 crazy.

Update: Windows High Contrast Mode

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

The embedded sample above is updated with the WHCM styles, but here are the relevant additions (none of the other styles were changed, thanks to the magic of media queries):

@media screen and (-ms-high-contrast: active) {
  input[type=checkbox] + label:before {
    content: ' ';
  input[type=checkbox]:checked + label:before {
    content: '\002713';
  input[type=radio] + label:before {
    content: ' ';
  input[type=radio]:checked + label:before {
    content: '\00d7';
    line-height: .75;
    background-color: transparent;
    box-shadow: none;

I removed the shadow and background color on the radio button for no reason other than it might matter in the future if Microsoft continues to tweak how WHCM honors backgrounds. You can dump those styles today and all will be well.

I also have a screen shot:

Screen shot of my checkbox / radio button in Windows High Contrast Mode.
Screen shot in Microsoft Edge 40.15063.0.0 / Microsoft EdgeHTML 15.15063 running on Windows 10 with the Creators 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.



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.


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.


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).


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.


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.

Leave a Comment or 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.