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 \00a0. 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.

Short URL:


Post filed under: css.

Skip to comment form.

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

  2. Ryan Seddon says:


    Fairly simple change just add a class to the containing element around the custom checkbox. In my case I created a .default class that won’t have the custom styling applied and just show the browser default checkbox.

  3. Marius says:

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

  4. Vinicius says:

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

  5. Anders says:

    Great work!

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

  6. Ryan Seddon says:

    You can’t style file inputs directly you can do custom ones with JavaScript.

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

  8. Ryan Seddon says:

    @Saba –

    Hmm seems like a weird issue, the only I could get around it was to set the sprite on the :before pseudo-element see this demo. This sits the sprite in the correct position.

  9. mutanic says:

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

  10. 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?

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

  12. 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?

  13. 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: 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?

  14. Ryan Seddon says:

    Thanks Lisa thanks been fixed now.

  15. Ryan Seddon says:

    @Tim – Good idea i’ll take a look at using something like to generate some webfonts for custom radio/checkboxes.

  16. 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;}

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

  18. 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?

  19. Ryan Seddon says:


    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.

  20. Ryan Seddon says:


    Shouldn’t cause any issues.

  21. Luís Serrano says:


    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.


  22. Ryan Seddon says:

    @Luis –

    Have you got a test case with the issue? I just tried the article demo on my iPhone and it worked correctly.

  23. Luís Serrano says:


    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


  24. malei0311 says:

    your work is awesome, and I also see your slides: , it’s cool and amazing!

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

  26. Martijn says:

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

  27. Sedat Kumcu says:

    Thanks for this useful artile. Good works.

  28. JM says:

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