Hajime, the duck guy

Friday, September 13, 2024, by Hajime Yamasaki Vukelic

Custom checkboxes, the hard and the easy

A checkbox is one of the common controls built as part of a (custom) UI toolkit. In this post, I'm going to walk you through one of the usual approaches taken by front end developers when dealing with this control, and then I will show you an alternative approach that is much easier and also much better from both DX and UX points of view.

At the end of each section, you will find a link to the working example on CodePen.

Why checkboxes are customized

Let's talk about why checkboxes are customized to begin with.

In most browsers, the checkbox is presented as a tiny box with an associated label. For example:

Screenshot of a default checkbox in Chrome
Default checkbox in Chrome-based browser on Windows has a small box next to the text

The click target is very small, which is generally bad for most users, but especially users with reduced motor ability. It is also easy to miss when scanning larger forms. Although the label, when properly associated with the input becomes part of the click target, there are users who don't know about this and will still try to click on the box rather than the label. This is especially the case for users who have encountered one poorly coded checkbox too many.

What is big enough, you ask? The minimum click target size should be 44 CSS pixels. We are going to treat this as minimum size at default font size, as we want the checkbox size to scale proportionately with its label. Therefore, this is equal to 2.75em. This is explained in the WCAG success criterion 2.5.5, "Target Size". This is a AAA criterion, meaning that it's not usually considered mandatory, but it's not difficult to achieve, so we are going to apply it in our examples.

Another reason we want to customize the checkbox is to achieve an entirely different presentation, such as a toggle switch.

In this post, we are going to create two variants of the checkbox. A classic checkbox with a larger click target and some transitions, and a toggle switch.

The classic checkbox, the usual hard way

One of the more common ways to implement a custom checkbox is to substitute it for a hidden input, and provide an entirely different set of controls in its stead. Here's one example of this:


<div id="consent-field">
    <x-checkbox data-value="granted">
        <input type="hidden" name="consent" value="">
        <span class="checkbox-box"
              role="checkbox"
              aria-labelledby="consent-label"
              aria-checked="false"
              tabindex="0">
        <svg><use href="#check"/></svg>
        <svg><use href="#indeterminate"/></svg>
      </span>
    </x-checkbox>
    <span id="consent-label" class="checkbox-label">
        You shall give up your privacy
    </span>
</div>

The #consent-field is used to group the label and the checkbox, and control the layout. This is necessary because the the checkbox is going to be significantly taller than the label.

I've used the <x-checkbox> custom element to add behavior to the control. It can have two attributes: data-value and data-indeterminate. The data-value is a replacement for the checkbox's value attribute, and the other attribute is used to declare the indeterminate state. Note that the custom element does not wrap the label, just the form control.

I've used a hidden input to hold the actual value. Empty (or missing) value attribute on this input is the unchecked state, while presence of the value is the checked state. This is irrespective of the data-indeterminate attribute, just like in the native checkbox.

The .checkbox-box element represents the visible checkbox control — the box. It contains two SVGs that reference a SVG spritesheet not shown in the example. The reason I use a spritesheet instead of a web font is that web fonts can be replaced by the user and break the UI. This is common with dyslexic people, for instance. The two SVGs each represent an icon that is drawn in the box for the appropriate states.

Since this 'box' is not a real checkbox, I've given it the checkbox ARIA role to signal to the screen readers that that's what this box is for. I've also associated it with the label by pointing to the label element using aria-labelledby. Finally, I'm using the aria-checked attribute to signal its state.

Finally, the .consent-label represents the label of the form control.

CSS is where the most action happens, so I'll walk you through it bit by bit.

#consent-field {
    display: flex;
    align-items: center;

    gap: 0.1em;
}

The #consent-field wrapper defines the layout of the checkbox-label combination. I make sure they are vertically aligned, and that there's a tiny gap between them. I'll get back to this gap later.

x-checkbox {
    flex: none;

    display: inline-flex;
    align-items: center;
    justify-content: center;

    width: 2.75em;
    aspect-ratio: 1;

    cursor: pointer;
    border-radius: 0.4em;
}

x-checkbox:hover {
    background: #efefef;
}

The <x-checkbox> element is styled so that it doesn't expand or shrink within a flex layout. It defines the outer perimeter of the checkbox — the total clickable target area — which we said would be 2.75em. To avoid specifying the same value twice, I use aspect-ratio to make it square. I gave the element a hover background so that it highlights on hover. It does not highlight on focus to avoid unnecessary distractions for keyboard-only users.

x-checkbox .checkbox-box {
    flex: none;

    position: relative;

    display: inline-flex;
    align-items: center;
    justify-content: center;

    width: 1.2em;
    aspect-ratio: 1;

    border: 0.062em solid #737373;
    background: #f2f2f2;
    border-radius: 25%;
    box-shadow: inset 0.1em 0.1em 0.2em rgb(0 0 0 / 30%)
}

Since making the checkbox itself 2.75em would result in an ludicrously oversize checkbox, I have nested .checkbox-box inside <x-checkbox>, and will style the nested element as the actual checkbox. So the outer area that is 2.75em squared is the click target, and the inner, smaller box is just the state indicator and a focus target.

Most of the styling is cosmetic, but I did make sure it has a clearly visible border to make it easier to spot on the page, and I've used a larger 1.2em size to make the target easier to click on for those that feel an urge to be very precise about it. In case you're wondering, 0.062em is about 1px at the default font size. Increasing the default font size will also increase the border width.

x-checkbox svg {
    display: none;

    width: 1em;
    height: 1em;
}

x-checkbox .checkbox-box[aria-checked=true] svg:first-child,
x-checkbox[data-indeterminate] .checkbox-box[aria-checked=false] svg:last-child {
    display: inline-block;
}

x-checkbox .checkbox-box[aria-checked=true],
x-checkbox[data-indeterminate] .checkbox-box[aria-checked=false] {
    background: #3777b3;
    color: white;
}

I've used the aria-checked attribute to set the checkbox icon and background.

And let's take a look at the JavaScript bits. Since a lot of the work is already done by the CSS, we don't need tons of JavaScript. Just a sprinkle here and there.

customElements.define('x-checkbox', class extends HTMLElement {
    connectedCallback() {
        this.setup?.()
        delete this.setup
    }

    setup() {
        let box = this.querySelector('.checkbox-box')

        { // Associate label
            let labelId = this.querySelector('.checkbox-box').getAttribute('aria-labelledby'),
                label = document.getElementById(labelId)

            label?.addEventListener('click', () => {
                box.focus()
                box.click()
            })
        }

        this.addEventListener('click', () => {
            let input = this.querySelector('input')

            // Toggle the input value
            if (input.value) input.value = ''
            else input.value = this.dataset.value
            box.setAttribute('aria-checked', (
                !!input.value ||
                this.hasAttribute('data-indeterminate') &&
                'mixed'
            ))
        })

        this.addEventListener('keydown', ev => {
            if (ev.code == 'Enter') ev.target.click()
        })
    }
})

We start with my standard boilerplate for custom elements:

connectedCallback()
{
    this.setup?.()
    delete this.setup
}

The makes the custom element perform a one-time setup so that it doesn't result in multiple event listener registration in the event the custom element is removed from the document and then added back in. I have explained this in another post.

{ // Associate label
    let labelId = this.querySelector('.checkbox-box').getAttribute('aria-labelledby'),
        label = document.getElementById(labelId)

    label?.addEventListener('click', () => {
        box.focus()
        this.click()
    })
}

Because this is not a real form control and the label isn't real either, I need to replicate the behavior of the label. I locate the label using the aria-labelledby attribute, and add the click event listener to it. I want the checkbox to be both focused and clicked when the label is clicked.

this.addEventListener('click', () => {
    let input = this.querySelector('input')

    // Toggle the input value
    if (input.value) input.value = ''
    else input.value = this.dataset.value
    box.setAttribute('aria-checked', (
        !!input.value ||
        this.hasAttribute('data-indeterminate') &&
        'mixed'
    ))
})

The click handler for the checkbox is attached to the outer container, <x-checkbox> rather than the inner box. This gives the user the maximum surface for clicks. The aria-checked attribute has three possible states: 'true', 'false', and 'mixed'. Note that these values are technically string, but I'm taking advantage of automatic coercion to coerce Boolean values to strings — true will be set as 'true' and the same for false.

The Boolean expression sets the value to 'true' only if the checkbox is checked, and otherwise falls back on the presence of the data-indeterminate attribute. When the attribute is present, the value is 'mixed', otherwise it's 'false'. Here's a table that shows the relationships between these values:

input.value data-indeterminate aria-checked
yes any 'true'
no no 'false'
no yes 'mixed'

And the last bit:

this.addEventListener('keydown', ev => {
    if (ev.code == 'Space') this.click()
})

The keydown listener traps the spacebar key and invokes the click handler.

The result looks like this:

Screenshot of the result in the default state
Unchecked state

When hovering over the checkbox, you can see the hover effect I applied to the checkbox's outer perimeter. That is the total click target area. The small gap I mentioned earlier is there to prevent the text from being right on the edge of the hover area. Makes it look a bit neater.

Screenshot of the hover state
Hover state

Finally, the checked state.

Screenshot of the checked state
Checked state

You can find the code for this in the pen.

The classic checkbox, the easy way

Now let's take a look at the easy way. What I don't like about the usual solution is that they don't leverage the existing behavior of the native checkbox itself. It already does exactly what we need, and we just don't like what it looks like. Can we somehow solve just the looks?

Turns out it's actually rather easy to do. The main reason checkboxes are somewhat difficult to customize is the default styling cannot be customized that much. However, it is possible to completely cancel the default appearance, in which case, the checkbox becomes just like any other element, like, say, a <span>.

Planning ahead to take advantage of the sibling combinators, I can simplify the markup as follows:

<label id="consent-field">
    <x-checkbox>
        <input type="checkbox" name="consent" value="granted">
        <svg>
            <use href="#check"/>
        </svg>
        <svg>
            <use href="#indeterminate"/>
        </svg>
    </x-checkbox>
    <span id="consent-label" class="checkbox-label">You shall give up your privacy</span>
</label>

Let me walk you through the changes. Most importantly, we are now using a real checkbox. This has major implications on what we don't have to do. For instance, we don't have to add any ARIA attributes because the accessibility technologies have native support for native form controls. It also allows us to use an actual <label> element as the outer-most element.

What was previously the .checkbox-box is now replaced by the actual checkbox element. Since we cannot nest anything inside inputs, we are putting our SVG next to the input. I know I can get to them using the ~ and + combinators, so that should work.

Moving on to CSS.

#consent-field {
    display: flex;
    align-items: center;
    gap: 0.1em;
}

This portion is identical to the previous example.

x-checkbox {
    flex: none;

    display: inline-flex;
    align-items: center;
    justify-content: center;

    width: 2.75em;
    aspect-ratio: 1;

    cursor: pointer;
    border-radius: 0.4em;
}

x-checkbox:hover {
    background: #efefef;
}

Similar to the previous example, I'm again setting up the outer perimeter for the checkbox which will be used as the click target. Same for the hover state.

x-checkbox input {
    appearance: none;
    display: inline-block;

    position: relative;

    display: inline-flex;
    align-items: center;
    justify-content: center;

    width: 1.2em;
    aspect-ratio: 1;

    border: 0.062em solid #737373;
    background: #f2f2f2;
    border-radius: 25%;
    box-shadow: inset 0.1em 0.1em 0.2em rgb(0 0 0 / 30%);
    
    font: inherit;
}

I used appearnce: none to reset the default appearance of the checkbox. The rest of the code is exactly the same as in the previous example.

The font: inherit rule resets the font properties. By default the form controls don't use the default font size of the document, so it's a good idea to reset those to keep things consistent, especially if you use rem/em units (as you should).

x-checkbox svg {
  display: none;
  
  position: absolute;
  margin-left: 0.062em;
  margin-top: 0.062em;
  
  width: 1em;
  height: 1em;
}

x-checkbox :checked + svg,
x-checkbox :indeterminate ~ svg:last-child {
  display: inline-block;
}

x-checkbox :is(:checked, :indeterminate) {
  background: #3777b3;
}

x-checkbox :is(:checked, :indeterminate) ~ svg {
  color: #fff;
}

I get a bit of simplification in the selector thanks to the checkbox having a pre-defined set of pseudo-classes for its state. Hopefully I don't need to explain these selectors. I'll briefly touch on the sibling combinators, though. The next sibling combinator, + can target the immediately following sibling only. So I use this to target the first SVG, which is the check mark. The subsequent sibling combinator, ~, targets all subsequent siblings that match the query. Therefore, I have to additionally qualify the SVG element as being :last-child, otherwise it also catches the other one.

Finally, let me show you the JavaScript:

Yes, it's empty. Since the checkbox is already doing everything I need, there's no need to add any JavaScript. I use the custom element as simply a namespace for the CSS selectors.

The results are identical in terms of appearance, so I won't repeat the screenshots.

You can find the code in the pen.

The toggle button, the usual hard way

Once we have the working checkbox implementation, turning it into a toggle button is rather straightforward. First I empty the contents of the .checkbox-box, as I no longer need to show any icons.

<x-checkbox data-value="granted">
    <input type="hidden" name="consent" value="">
    <span class="checkbox-box" role="checkbox" aria-labelledby="consent-label"
          aria-checked="false" tabindex="0">
    </span>
</x-checkbox>

Next, I will adjust the styling to produce the toggle button:

x-checkbox {
    /* existing code .... */
    
    /* 
    width: 2.75em;
    aspect-ratio: 1; 
    */
    height: 2.75em;
    padding: 0 0.625em;

    /* existing code .... */
}

Since toggle button is not square, I don't need the aspect-ratio property anymore. And instead of the width, I will set the height. Because there's still the hover background, I will add paddings on either side. The 0.75em padding is derived from the intended height of the visible part of the button, which is going to be 1.5em. This should create a nice even hover background around the inner element.

x-checkbox .checkbox-box {
    /* existing code .... */

    /*
    display: inline-flex;
    align-items: center;
    justify-content: center;
  
    width: 1.2em;
    aspect-ratio: 1;
    */
    height: 1.5em;
    aspect-ratio: 2;

    /* existing code .... */
    
    /*
    border-radius: 25%;
    */
    border-radius: 50% / 1.5em;

    /* existing code .... */
}

I no longer need the checkbox to be a flexbox, so I'll remove that. The checkbox's inner box looks mostly like it should except for the shape and size. Therefore I change the size to be 1.5em and set the aspect ratio to 2. The reason I use 2 is it makes it a bit easier to calculate the position of the toggle tab. Not a huge difference but it's a bit more convenient.

x-checkbox .checkbox-box::before {
    content: '';
    display: inline-block;

    height: 100%;
    aspect-ratio: 1;

    background: #fff;
    border-radius: 50%;
    border: 0.062em solid #737373;
    box-shadow: 0.1em 0.1em 0.3em rgba(0 0 0 / 30%);

    transition: transform 0.2s;
}

x-checkbox .checkbox-box[aria-checked=true]::before {
    transform: translateX(100%);
}

For the toggle button tab, I'm using the ::before pseudo-element. I give it a pure white background to stand out from the box. The box shadow is made stronger so that it looks like the tab is sticking out.

The position of the tab is controlled by the aria-checked attribute.

Lastly, we change the background color when the

As far as JavaScript go, it's pretty much the same except that I removed the support for indeterminate state, as it makes no sense for toggle buttons.

box.setAttribute('aria-checked', !!input.value)

Let me show you what it looks like. First the default state:

Screenshot of the custom toggle button
Off state

The hover effect produces a similar effect as before. It's just following the shape of the visible part of the button.

Screenshot of the hover state
Hover state

When the button toggles on, the background color is changed. This is the same as with the original checkbox:

Screenshot of the hover state
On state

It is also possible to create a single stylesheet and a single custom element that supports both these styles. However, I think it was worthwhile to demonstrate the process of modifying the code to see how much of what I needed to change when the concerns are properly separated.

Again, here's the code in a pen.

Toggle button, the easy way

Technically, modifying the classic checkbox that is based on the input element is not any easier (or harder) than modifying the one that is not based on an input. We are doing more or less the same thing.

We adjust the dimensions of the <x-checkbox> and the <input> elements, remove the SVG, and that's pretty much it. The only difference here is that we don't need to touch any JavaScript because there is none.

You will find the working example in a pen.

Conclusions

The main take-away in this post is that basing your solution on the wrong starting point results in more code and more work. Moreover, you could always forget something. For instance, the solutions based around a hidden input with entirely custom form controls loses the feature where you submit the form by pressing Enter/Return while the control is focused. There's also no easy way to add constraint validation. And so on and so forth. In other words, you would be doing more work to get less features. Not worth it.

As a rule of thumb, you always want to start with something that's close to what you need in terms of behavior.

In all of these cases, I've used CSS selectors to implement the crucial parts of the UI. This is one of those cases where doing CSS before JavaScript makes a lot more sense. You generally want to get to a state where you can manually toggle attributes and get the desired visual feedback before you proceed to 'automate' that using custom elements, jQuery or some other method. CSS's reactive nature does away with a lot of wiring in your JavaScript code.

Posted in Programming tips
Back to top