Accessible, Custom Designed Checkbox and Radio Button Inputs Styled with CSS (and a dash of jQuery)

April 2020 note: Hi! Just a quick note to say that this post is pretty old, and might contain outdated advice or links. We're keeping it online, but recommend that you check newer posts to see if there's a better approach.

Styling checkbox and radio button inputs to match a custom design is nearly impossible because neither reliably supports basic CSS, like background colors or images; it’s even a challenge to get the margins to appear consistently across browsers. To remedy this we developed a concise jQuery plugin based on progressive enhancement that leverages an input element’s built-in functionality and accessibility features and works in all modern browsers without added markup or mandatory CSS classes.

In the past few years web application interface designs have evolved from flat metal gray to having rich color palettes and dimensional background images thanks to the adoption of web standards and advanced CSS techniques. Form checkboxes and radio buttons have lagged behind, though, because only a few browser vendors have built in support for styling these elements with CSS, and inconsistently at that. A quick search turned up several workaround scripts that cleverly use a combination of JavaScript and CSS to apply a custom skin to these form elements, however they sometimes do more work than we need them to by inserting replacement markup, or requiring that specific classes be assigned to every applicable form element.

When considering how to build our own customized input script, we set out to do as little as possible — on their own, checkboxes and radio buttons capture data and display feedback, and we wanted to use that native functionality and not reinvent it using JavaScript. If we let the user interact with the inputs as expected, we’d only have to apply a lightweight script to layer on visual enhancements for richer feedback. And by keeping our hands off the inputs’ native functionality, we were able to preserve their inherent keyboard accessibility and avoid the need to use ARIA attributes since a screen reader is still interacting with the native form element.

Update: We’ve received a lot of thoughtful comments on this technique, one of which stood out: what happens when images aren’t available? In our first pass at this widget, we hid the input element by positioning it off the page, and then styled the label element with background images that mapped to click states (unchecked, hover, checked) – so it became an unusable input without images. We’ve since re-factored the markup and script to ensure that the widget works even when images don’t load. Instead of positioning the input off the page, we’ve positioned it directly behind the styled label, so if the label’s background image fails, the input remains visible. And because the label and input share a click event, when the user clicks the transparent label over the input, the input updates accordingly.

When JavaScript is enabled, users see custom input checkboxes and radio buttons as shown below; when users are browsing with JavaScript disabled or with a screen reader, they interact with standard, unstyled input elements.

Markup

Permalink to 'Markup'

We start with basic HTML for each input that follows web standards conventions:

  • assigned a unique id and value to each input
  • paired the input with a label element
  • included a “for” attribute on each label that references the preceding input’s id

Each radio button input also needs a common name attribute to group it with a set.

<form>
    <fieldset>
        <legend>Which genres do you like?</legend>

        <input type="checkbox" name="genre" id="check-1" value="action" />
        <label for="check-1">Action / Adventure</label>

        . . .
    </fieldset>
    <fieldset>
        <legend>Caddyshack is the greatest movie of all time, right?</legend>

        <input type="radio" name="opinions" id="radio-1" value="1" />
        <label for="radio-1">Totally</label>

        . . .
    </fieldset>
</form>

Pairing the inputs and labels correctly is essential to how this plugin works. As stated in the HTML spec, “When a LABEL element receives focus, it passes the focus on to its associated control.” Browsers have standardized this behavior so that when you click a label, the click is passed on to the input — in other words, the label and input act as a single element when marked up this way. Because we don’t have to interact with the input directly, we can hide it from view with CSS and apply styles to the label to make it look like a customized checkbox or radio button.

When the page loads, the plugin script finds each input/label pair and wraps it in a div. Each wrapper div is assigned a class to it based on the type of input it contains:

<div class="custom-checkbox">
    <input id="check-3" type="checkbox" value="epic" name="genre"/>
    <label class="" for="check-3">Epic / Historical</label>
</div>

Styles

Permalink to 'Styles'

First, we absolutely positioned the input and label pair so that we could layer the label over the input, like a mask. For this to work, we relatively positioned the wrapper div to contain the input and label:

/* wrapper divs */
.custom-checkbox, .custom-radio { position: relative; }

/* input, label positioning */
.custom-checkbox input,
.custom-radio input {
    position: absolute;
    left: 2px;
    top: 3px;
    margin: 0;
    z-index: 0;
}

.custom-checkbox label,
.custom-radio label {
    display: block;
    position: relative;
    z-index: 1;
    font-size: 1.3em;
    padding-right: 1em;
    line-height: 1;
    padding: .5em 0 .5em 30px;
    margin: 0 0 .3em;
    cursor: pointer;
}

Next, we styled each type of label (checkbox and radio button) with a background image — we used an image sprite for all states: default, hover, and checked:

.custom-checkbox label {
    background: url(images/checkbox.gif) no-repeat;
}

.custom-radio label {
    background: url(images/radiobutton.gif) no-repeat;
}

And added classes for hover and checked states that repositioned the background sprite accordingly. We also included a class for the “focus” state for keyboard users.

.custom-checkbox label, .custom-radio label {
    background-position: -10px -14px;
}

.custom-checkbox label.hover,
.custom-checkbox label.focus,
.custom-radio label.hover,
.custom-radio label.focus {
    background-position: -10px -114px;
}

.custom-checkbox label.checked,
.custom-radio label.checked {
    background-position: -10px -214px;
}

.custom-checkbox label.checkedHover,
.custom-checkbox label.checkedFocus {
    background-position: -10px -314px;
}

.custom-checkbox label.focus,
.custom-radio label.focus {
    outline: 1px dotted #ccc;
}

Script

Permalink to 'Script'

Because the label-input association takes care of clicking the hidden input for us, we only had to write a really simple jQuery plugin that appends a class to each input on hover, on focus, and on click:

jQuery.fn.customInput = function(){
    $(this).each(function(i){
        if($(this).is('[type=checkbox],[type=radio]')){
            var input = $(this);

            // get the associated label using the input's id
            var label = $('label[for='+input.attr('id')+']');

            //get type, for classname suffix
            var inputType = (input.is('[type=checkbox]')) ? 'checkbox' : 'radio';

            // wrap the input + label in a div
            $('').insertBefore(input).append(input, label);

            // find all inputs in this set using the shared name attribute
            var allInputs = $('input[name='+input.attr('name')+']');

            // necessary for browsers that don't support the :hover pseudo class on labels
            label.hover(
                function(){
                    $(this).addClass('hover');
                    if(inputType == 'checkbox' && input.is(':checked')){
                        $(this).addClass('checkedHover');
                    }
                },
                function(){ $(this).removeClass('hover checkedHover'); }
            );

            //bind custom event, trigger it, bind click,focus,blur events
            input.bind('updateState', function(){
                if (input.is(':checked')) {
                    if (input.is(':radio')) {
                        allInputs.each(function(){
                            $('label[for='+$(this).attr('id')+']').removeClass('checked');
                        });
                    };
                    label.addClass('checked');
                }
                else { label.removeClass('checked checkedHover checkedFocus'); }

            })
            .trigger('updateState')
            .click(function(){
                $(this).trigger('updateState');
            })
            .focus(function(){
                label.addClass('focus');
                if(inputType == 'checkbox' && input.is(':checked')){
                    $(this).addClass('checkedFocus');
                }
            })
            .blur(function(){ label.removeClass('focus checkedFocus'); });
        }
    });
};

Usage

Permalink to 'Usage'

Simply call the customInput() method on any input element or group of elements (more on using jQuery):

$('input').customInput();

Download the code

Permalink to 'Download the code'

This plugin script requires jQuery’s core library, available for download at jquery.com or link directly from Google’s code repository. Download the plugin script, customInput.jquery.js, and feel free to use it in your projects (it’s dual licensed under the MIT and GPL open source licenses; refer to author’s notes).

All blog posts