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 = evt.target;
		
    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.

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

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.

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

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.

Drawbacks

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.

Short URL: http://cssn.in/ja/023

 

Post filed under: css.

Skip to comment form.

  1. Hey dude, great post. This is going to a surefire fix to my problem. I wonder if you had any luck doing this same thing to combobox controls or if you had to resort to a js control of some sort. Go ninja, go ninja, go!

  2. Carlos Goncalves says:

    Hello, I´m using your solution, but It has a bug when I put a checkbox in the same line or row. They are in two div box, but when I select one of them in a box, other checkbox in the other box are selected. I tested this in the last Chrome and Firefox. How can I prevent this?

    The example: http://goo.gl/Ls71j

    Thanks a lot.

  3. James Anelay says:

    What about if you have a lable before the input can you still make this work?

    • Ryan Seddon says:

      @James Anelay

      This technique relies on hiding the input and using the label to trigger the input change, and the order of the markup needs to be input > label as you can’t do a selector to target a preceding sibling.

  4. PixelZombie says:

    Awesome technique. I’m implementing this at the moment. I just have one thing: the :focus statement causes Firefox to (whatever you do) keep the focused image and css style as long as you click anywhere outside the input or label. To prevent this behaviour I commented out the :focus styles and it’s working like a charm.
    Thanks a million!

  5. Bricolage says:

    OK, thank you very much ;)

  6. Christian says:

    SASS (scss) version of the code, minus styles that were not completely necessary to the solution.

    (… I hope the comments are formatted to preserve tabs…)

    p > input[type=checkbox], p > input[type=radio] {
        position: absolute;
        left: 0;
        opacity: 0;
    }
    input[type=checkbox] + label, input[type=radio] + label {
        padding-left: 20px!important;
    }
    
    p:not(#foo) {
        > input + label {
            background: image-url(‘fancyCheckbox.png’) 0 -1px no-repeat;
            height: 16px;
        }
        > input[type=radio] + label {
            background-position: 0 -161px;
        }
        /* Checked styles */
        > input[type=radio]:checked {
            + label {
                background-position: 0 -241px;
            }
            &:hover + label, &:focus + label, + label:hover{
                background-position: 0 -261px;
            }
        }
        > input[type=checkbox]:checked {
            + label {
                background-position: 0 -81px;
            }
            &:hover + label, &:focus + label, + label:hover{
                background-position: 0 -101px;
            }
        }
        /* Hover & Focus styles */
        > input[type=checkbox] {
            &:hover + label, &:focus + label, + label:hover{
                background-position: 0 -21px;
            }
        }
        > input[type=radio] {
            &:hover + label, &:focus + label, + label:hover{
                background-position: 0 -181px;
            }
        }
        /* Active styles */
        > input[type=checkbox]{
            &:active + label, + label:hover:active{
                background-position: 0 -41px;
            }
        }
        > input[type=radio]{
            &:active + label, + label:hover:active{
                background-position: 0 -201px;
            }
        }
        > input[type=checkbox]:checked {
            &:active + label, + label:hover:active{
                background-position: 0 -121px;
            }
        }
        > input[type=radio]:checked {
            &:active + label, + label:hover:active{
                background-position: 0 -281px;
            }
        }
        /* Disabled styles */
        > input[type=checkbox]:disabled{
            + label, &:hover + label, &:focus + label, + label:hover, + label:hover:active{
                background-position: 0 -61px;
            }
        }
        > input[type=radio]:disabled{
            + label, &:hover + label, &:focus + label, + label:hover, + label:hover:active{
                background-position: 0 -221px;
            }
        }
        > input[type=checkbox]:checked{
            &:disabled + label, &:hover:disabled + label, &:focus:disabled + label, &:disabled + label:hover, &:disabled + label:hover:active{
                background-position: 0 -141px;
            }
        }
        > input[type=radio]:checked{
            &:disabled + label, &:hover:disabled + label, &:focus:disabled + label, &:disabled + label:hover, &:disabled + label:hover:active{
                background-position: 0 -301px;
            }
        }
    }
    
  7. Jason says:

    I can’t get this to work with ASP.NET MVC 3 when using Html.CheckBox. I think it might be due to the way it creates a separate hidden element with the same name as the check box but I’m not sure. Any ideas?

     

    • Ryan Seddon says:

      @Jason

      The Html.CheckBox helper actually creates two inputs the second is hidden and appears as a direct sibiling to the visible checkbox e.g.

      <input id="testy" name="testy" type="checkbox" value="true">
      <input name="testy" type="hidden" value="false">
      <label for="testy">Testy</label>
      

      That kills the direct sibling combinator I use in the CSS, the +. I would recommend not using the helper and just writing the html yourself.

  8. John Golden says:

    Has anyone inspected the DOM in FF or Chrome to see what the checked value is showing up as for these checkboxes? I’ve been having problems with a jQuery plugin in a .NET environment where the checkboxes appear visually to be checked but their checked property wasn’t actually checked. You can see this by opening up the DOM inspector. I tested these checkboxes in Chrome and FF and I’m seeing the same behavior. Anyone else?

  9. cudjex says:

    thanks.good tutorial..

  10. rara says:

    Nice article! :)

  11. Devin says:

    Awesome article, and seemingly the best pure CSS option on the net! Thanks so much!

    I have however encountered an inconsistent problem. I have applied the CSS to our ASP.NET site. It works perfectly for checkboxlists, radiobuttons, radiobuttonlists. It does however have an inconsistent problem with the standalone checkbox control, which sometime remains highlighted (focused) after clicking off it, or the checkbox doesn’t always check on first click, whilst the checkboxlist renders and interacts perfectly everytime.

    I have pasted the ASP.NET rendered HTML and further down the CSS which applies. I would really appreciate if you could point out why the problem might be occurring. Thanks again!

  12. Devin says:

    Hi Ryan

    I have been able to replicate the setup by pasting my rendered ASP.NET html and the associated CSS into the following fiddle: http://jsfiddle.net/FKRaX/

    Please note the single checkbox and single radio controls lacking sprite responsiveness in IE9 vs the checkboxlist and radiolist.

    Otherwise a fantastic solution!

    Hope there is an answer to this problem. I considered that I may need to use modernizer to detect IE and use unique images as opposed to a sprite. Would this be an option? Is there a better option?

    Cheers Devin

    • Ryan Seddon says:

      @Devin

      This is a really bizarre, I dug in and discovered some really odd results. My conclusion is this is a bug with the IE9 selector engine when using spans with pseudo classes.

      Take a look here at this reduced testcase http://jsfiddle.net/ryanseddon/GgsaM/6/

      The first checkbox is fast in IE9 the second very slow, only difference is one has spans the other has divs…I know weird. I aware with .NET you have very little control over the html spat out but the the only solution is to not wrap the input/label in a span.

  13. Devin says:

    Just thought I would let you know, that the most efficient way to overcome the problem for .Net developers is not to use a sprite for IE9, but rather use individual images. While in a custom web control the default output tag of span can be replaced by a div at render, this cannot be changed for the default control set. Thus using individual images is far more efficient that creating your own set of controls.

    Cheers Devin

  14. Elliott says:

    Brilliant Idea!

  15. Devin says:

    Heya Ryan

    So I posted in May and have since been using the code on our site, with some minor adaptations to get it working nicely within .NET sites. I have now encountered a new scenario, where on rare occasions I want to disable all the customisations on “SOME” checkboxes. How would you recommend achieving this?

    Thanks, Cheers Devin

  16. Devin says:

    Can you please indicate the syntax. Would it merely be placing ‘.myclass ‘ infront of all the lines.

    Also what I would prefer to acheive is that by default your look is applied to all. Any I can use some kind of clear/reset to default where I have exceptions which need to look different to the custom look. Not sure if this is possible though.

    Cheers Devin

  17. Marius says:

    There’s any possibility to use these forms on IE8???

  18. Vinicius says:

    And if I add another, hidden input eg check buga why?

  19. Anders says:

    Great work!

    Do you know it this is a good method for styling a “input type file” also?

  20. Saba says:

    Hi Ryan,
    I just viewed the demo on an iphone 5 Safari and it works just as in the desktop mode, except the sprites are shifted up/down by about 5-8 pixels. It seems to be a Safari problem. It looks fine under an HTC phone. But my main concern is Safari. Would you let us know if there’s some kind of work around to get it to work under Safari mobile? Thanks so much for this awesome tutorial.

  21. mutanic says:

    How do I change the icon for asp:checkboxlist & asp:radiobuttonlist???

  22. Martijn says:

    Yessss, well almost.
    Android supports p:not(#foo) perfectly fine, but that does not go for :checked. Android, to my giant gaping surprize, does NOT support :checked on checkboxes/radiobuttons (honestly, Google, come on)

    So the :not() trick isn’t going to cover it, I’m afraid… Any other ideas?

    • Ryan Seddon says:

      I just tried this on Android 2.3 and 4.0.4 and my demo works as expected. Have you got a demo showing that it’s not working?

      I do know that older WebKit browsers have an issue with the general sibling combinator (~) when using :checked.

  23. tim says:

    sprites, background images, I find these to be hacky at best implementations [that I use anyway] but of late I’ve been switching pretty much everything over to webfonts since they are resolution and scaling independent. Being able to use webfont values as the radio or checkbox appearance would give me that one consistent thing I have been seeking – scaling. I haven’t found much to date about this technique, and so far my experiments have had varied amounts of failure. Have you considered this technique at all?

  24. Lisa says:

    I am using IE10. When I try to click on the round Radio button element and not on the Label beside then nothing happens: http://www.thecssninja.com/demo/css_custom-forms/ Just scroll to the paragraph: “Change direction for international text” Just click a radion button or the square Checkbox element with IE 10 and nothing gets selected. Can you fix that bug?

  25. Cam says:

    Thank you. Real nice solution for allowing a “NOT” option in checkbox lists to show a cross instead of a tick. My modified CSS is below to include hover. Simple add or remove ‘cross’ class when using not…
    .cross input:checked:hover + label {background:url(../../myResources/CrossedHover.png) 0px -1px no-repeat; height: 18px; padding: 1px 0 0 18px!important; margin: 3px 0 0 0; position:relative; left:-18px; top:0px;}
    .cross input:checked + label {background:url(../../myResources/Crossed.png) 0px -1px no-repeat; height: 18px; padding: 1px 0 0 18px!important; margin: 3px 0 0 0; position:relative; left:-18px; top:0px;}

  26. Raj Subramaniam says:

    ATG Droplets dynamically creates a hidden variable between the check box and label, so if the CSS styles are modified to the below code would cause any performance issue :
    p:not(#foo) > input[type=radio]:disabled:checked ~ input + label

  27. dam says:

    Really impressive work.
    Pamela Fox brought up an interesting point that I don’t have a good solution for yet.
    Depending on an ID and the for attribute makes these less portable. Changing the markup to nest the input in the label removes the need for the ID, but makes styling more challenging since :checked + label:after doesn’t seem to target the replacement item. Have you had any success when removing the ID?

    • Ryan Seddon says:

      @dam,

      This technique would be impossible without the correct markup order and the ability to create the association between the label and the input. You would have to use JavaScript to solve it.

  28. Luís Serrano says:

    Hi,

    I have this working successfully on desktop, inclusive IE. But I can’t put this working on iPad or any mobile device like iPhone or android..

    Can you help me?
    I pretty much set the code, working fine on desktop on all resolutions and browsers, the problem is mobile devices.

    Greetings.
    Luís.

  29. Luís Serrano says:

    Hi,

    It’s fixed now. I did it all right, and I tough that I pushed the last version to the server…
    It’s now all working.

    By the way, great article. Helped a lot with my relationship with IE! Ahah

    Greetings.
    Luís.

  30. malei0311 says:

    your work is awesome, and I also see your slides: http://www.thecssninja.com/talks/cssconf/ , it’s cool and amazing!

  31. BurninLeo says:

    That’s phantastic!
    There is one issue that does not actually refer to the underlying CSS: If using Windows 8 *and* the display is configured to show fonts larger (e.g., 125 % because of a high-res screen), Firefox will also scale websites by this amount. Including images. This can lead to rounding errors. In the live demo, my Firefox shows one pixel from the next state’s image below the input – so I suggest to spent one pixel buffer.

  32. Martijn says:

    @BurninLeo
    I suggest using vectors instead. All non-fossilized browsers support them by now, so go right ahead ;)

  33. Sedat Kumcu says:

    Thanks for this useful artile. Good works.

  34. JM says:

    Great work! But there’s no fall back when image is disabled on the browser. Any solutions?