Custom radio and checkbox inputs using CSS

In my never ending quest to find weird and wonderful ways to abuse CSS and all its little intricacies, I have come up with a pretty good way of using CSS to create custom radio and checkbox inputs without JavaScript, that are accessible, keyboard controlled, don’t use any hacks and degrade nicely in non supporting browsers. The journey wasn’t easy and I was on the brink of filing it in the “to crazy” folder, never to be seen again. Luckily I had a brain wave that paid off and actually allowed this to be a very viable solution that degrades beautifully and works in 80% of the browsers. This is my story.

It’s a bug, no it’s intended…well actually it’s still a draft

So upon my initial investigation of how doing something like this would be possible, I came across what I initially thought was a bug with Firefox and IE8. Applying CSS generated content to form elements doesn’t work, turns out Firefox and IE8 are following the CSS3 draft specification as specified in the CSS3 Generated and Replaced Content Module.

The box model defines different rules for the layout of replaced elements than normal elements. Replaced elements do not have ‘::before‘ and ‘::after‘ pseudo-elements;…

Form elements fall under the “replaced elements” category and therefore don’t allow :before and :after pseudo-elements on them. However Safari, Chrome and Opera all allow generated content on form elements. Further to the odd behaviour Chrome and Safari will only apply generated content on checkboxes, radios and file inputs whereas Opera will allow it for all form elements, see test case.

You can see my initial attempt at creating custom radios and checkboxes. This applied the generated content directly to the inputs themselves. Open up the demo in Safari or Chrome to see it working. This presented yet another problem this time with Opera, clicking the radio or checkbox directly would never actually check the box whereas with Chrome and Safari the box would be checked and the custom replacement would change state accordingly. But if you clicked the label, which uses the for attribute to create the relationship to the corresponding input, it would check the input in Opera and change the custom check state correctly.

I then thought I would see if I could fix it by using some JavaScript to check the input onclick, this worked for Opera but stopped it working in Chrome and Safari as it was putting it in an indefinite indeterminate state, as it was checked then quickly unchecked by the JavaScript.

document.body.onclick = function (evt) {
    var node =;
    if(node.nodeName === "INPUT") {
        var isChecked = node.checked;
        (isChecked) ?
            isChecked = false :
	    isChecked = true;

As there was no safe obvious way that didn’t involve browser sniffing or using bad object detection to determine if the browser behaved in this way. And the fact it was taking away from the whole point of this demo, to create a CSS only solution for custom forms, I scrapped my idea and went back to the drawing board.

Selectors, pseudo-classes & pseudo-elements

As you can see from the demo each radio and checkbox is replaced with a custom one. The difference here from my initial attempt, and to get around the fact you can’t apply generated content to form elements consistently, is to apply the generated content to the label rather than the input itself, and using some clever selectors I can determine the state of the radio/checkbox is in and adjust the custom replaced one accordingly. I also set the original input opacity to 0 so it won’t show through our custom one.

    <input type="radio" value="male" id="male" name="gender" />
    <label for="male">Male</label>

The HTML isn’t bloated and needs no extra mark-up

p:not(#foo) > input + label {
    background: url(gr_custom-inputs.png) 0 -1px no-repeat;
    height: 16px;
    padding: 0 0 0 18px;

Basically this selector is looking for a label that is immediately preceded by a sibling input, that is a child of a paragraph that doesn’t have an id of foo and lastly it has the :before pseudo-element so we can add our custom radio input. The reason for the not selector is to not apply these styles in IE8, I’ll explain why further down.

Update: Thanks to Lea and Mr.MoOx for their suggestions this now uses a sprite image. Rather than insert the image in the content property, I apply a background and insert 3 non-breaking space characters, since the content property doesn’t accept named entities I used the unicode equivalent of 0a0. The reason I insert 3 is because Firefox 1.5 won’t show the full background unless I put 3 in there and using a fullstop and applying color: transparent doesn’t work in FF 1.5.

To get this working in older browsers such as Firefox 3 and down I use a negative margin to get it to sit in the right place. Support for absolutely positioned generated content was only recently added in Firefox 3.5+. I then adjust the newer browsers by offsetting the left value to be equivalent of the left margin.

Update 2: Thanks to Marius for demonstrating that the :before pseudo-element is superfluous and adding the background directly to the label itself with a few adjustments works just fine and removes the hacky need for using the content property with 3 non-breaking space.

I moved the previous demo and it’s source files to a legacy folder so you can still get them if you want.

Now all the radios have been replaced by our custom versions we need to be able to tell if the radio is checked so we can adjust our custom state.

p:not(#foo) > input[type=radio]:checked + label {
    background-position: 0 -241px;

Again this is the same as the previous selector with one difference, we add the :checked pseudo-class available in the CSS3 selectors module to determine the radios state in CSS and change it upon the user checking the radio.

Of course the control doesn’t end there we also utilise the :hover, :focus, :active & :disabled pseudo-classes to change the radio on hover, when it has focus (for keyboard support), when the input is active (click and hold your mouse cursor to see this state change) and when it’s disabled. Mixing this with the :checked pseudo-class lets us control all possible states of the radio input and gives us great control and flexibility that doesn’t require a mouse nor JavaScript to control the states.

p:not(#foo) > input[type=radio]:hover + label,
p:not(#foo) > input[type=radio]:focus + label,
p:not(#foo) > input[type=radio] + label:hover { 
    background-position: 0 -181px; 
p:not(#foo) > input[type=radio]:hover:checked + label,
p:not(#foo) > input[type=radio]:focus:checked + label,
p:not(#foo) > input[type=radio]:checked + label:hover  { 
    background-position: 0 -261px;
p:not(#foo) > input[type=radio]:disabled + label,
p:not(#foo) > input[type=radio]:hover:disabled + label,
p:not(#foo) > input[type=radio]:focus:disabled + label,
p:not(#foo) > input[type=radio]:disabled + label:hover,
p:not(#foo) > input[type=radio]:disabled + label:hover:active { 
    background-position: 0 -221px; 
p:not(#foo) > input[type=radio]:disabled:checked + label,
p:not(#foo) > input[type=radio]:hover:disabled:checked + label,
p:not(#foo) > input[type=radio]:focus:disabled:checked + label,
p:not(#foo) > input[type=radio]:disabled:checked + label:hover,
p:not(#foo) > input[type=radio]:disabled:checked + label:hover:active {
     background-position: 0 -301px; 
p:not(#foo) > input[type=radio]:active + label,
p:not(#foo) > input[type=radio] + label:hover:active { 
    background-position: 0 -201px; 
p:not(#foo) > input[type=radio]:active:checked + label,
p:not(#foo) > input[type=radio]:checked + label:hover:active {
     background-position: 0 -281px; 

Another addition I have made to the CSS is also changing the input states when the user hovers, clicks or focuses on the label it will now change the input to reflect those actions.

IE8 is almost there

IE8 can do everything, except it doesn’t support the :checked pseudo-class and therefore makes this technique useless. So I use the not() pseudo-class, which IE8 doesn’t support, to work around this unfortunate lack of ability. Let’s hope IE9 adds the CSS3 selector module1. This of course degrades nicely in non-supporting browsers and fallback to the browser default form elements.

1 IE9 does support the CSS3 selector module so this technique has across the board support for all major browsers.

Accessible and friendly

CSS generated content doesn’t get in the way of a screen reader and they have a clear view of the form elements CSS generated content is no longer used it’s now a background image on the label rather than doing it on the generated content see update (I would appreciate any accessibility experts or screen reader users to please comment to correct or agree with me). Users, who have trouble operating a mouse or, like me, prefer navigating forms with the keyboard as it’s faster, aren’t left out. Tabbing through the inputs changes the states, pressing spacebar to check a radio or checkbox also changes the state. I also change the label colour and give it a text-shadow to give a nicer indication that the current input has focus.

The disabled and checked attributes work as intended with this solution and don’t require any trickery to achieve that.

    <input type="radio" disabled value="male" id="male" name="gender" />
    <label for="male">Male</label>
    <input type="radio" checked value="Female" id="female" name="gender" />
    <label for="female">Female</label>

As you can see from the above mark-up adding the disabled or checked attributes (disabled=”disabled” & checked=”checked” also work) will allow us to target those states using the :checked and :disabled pseudo-classes. As well as change the style if we use JavaScript to disable any inputs based on users actions.

Browser support and notes

As of writing the following browsers have been tested and known to work with the demo:

  • Firefox 1.5+
  • Opera 9.6+
  • Safari 3.2+*
  • iPhone/iPod Safari**
  • Chrome 4+
  • IE9+

Will run this demo through browsershots and update it accordingly if the browser support works in any lower versions mentioned

* This would work in Safari 3.2 except due to a bug. The checked pseudo-class will work just fine if it has the checked attribute on the input but won’t change the input state if the user checks the input with their mouse or keyboard (the actual input will check but the CSS state won’t update the custom input image).

* This now works in Safari 3.2 but, and a big but, the behaviour is quite bizarre. Clicking a radio or checkbox will change the custom state but clicking it again to uncheck won’t show until you have hovered away from or taken focus away from the input. If you have Safari 3.2 installed try the new demo to get a better understanding of what’s happening.

** To get the iPhone to work with this demo I had to apply the pointer-events CSS property. Since the input is actually sitting on top of the background image we no longer need to apply pointer events to the label.

p:not(#foo) > input + label {
    pointer-events: none;

Here for legacy reasons no longer needed for iPhone support

** To trigger the checkbox click when tapping a label add an onclick attribute to the label e.g.

<input type="checkbox" id="foo">
<label for="foo" onclick="">Tap me</label>

Since having the generated content sit over the top of the actual checkbox, iPhone Safari needed a kick up the bum with the pointer-events property so we could click through the custom checkbox to the actual checkbox. Desktop Safari works fine without it.


Resolved see update above. This technique has only 1 drawback I can think of, IE support is not a drawback for me, you can’t use a big sprite image to save all the radio and checkbox states, they need to be individual images. Using CSS generated content to insert an image doesn’t give you control of the image position like a background image does. The images are small in size and the initial load of each state shouldn’t be noticed. If however you wish to preload all the images take a look at my CSS based image preload technique. That technique will create quite a few http request depending on how many images you preload but since they’re small in size it shouldn’t be a major concern.

[link href=”″]