Illustration of four checkboxes with one left unchecked

Lim­it Check­box­es with Vanil­la JS

Cre­at­ing a key­board-friend­ly lim­it that makes sense to you and to a screenreader.





In a recent project, I was tasked with creating a selection gallery that had a limit of three selections. This blog post will cover:

View the final Codepen.

Background

The static design for this module starts with a selection of 12-16 “tiles” that the user can select and deselect as part of a quiz. The intended interaction was for the user to click on the tile as a whole, and the state of the selected tile would change to indicate it had been selected. This to me sounded like a great opportunity to incorporate checkboxes.

Also, because we’re using multiple inputs, this was also a great opportunity to incorporate the fieldset and legend elements, even though we don’t see these in the design file.

Start with Semantic HTML

Here’s our initial markup below:

<fieldset class="toggle-set"> <legend class="legend"> Check up to three flavors </legend> <div class="toggle-card"> <input class="checkbox" type="checkbox" name="mixed berries" id="checkbox-1"> <label class="label" for="checkbox-1">Mixed Berries</label> </div> <div class="toggle-card"> <input class="checkbox" type="checkbox" name="mixed berries" id="checkbox-2"> <label class="label" for="checkbox-2">Mixed Berries</label> </div> <div class="toggle-card"> <input class="checkbox" type="checkbox" name="mixed berries" id="checkbox-3"> <label class="label" for="checkbox-3">Mixed Berries</label> </div> <div class="toggle-card"> <input class="checkbox" type="checkbox" name="mixed berries" id="checkbox-4"> <label class="label" for="checkbox-4">Mixed Berries</label> </div> </fieldset>

By choosing checkboxes, I could be sure of three things: that this experience would be accessible to screenreaders, it would work without needing css, and it would be comprehensible to our javascript that we'll be needing in a little bit.

Layer of Style

While the semantic markup takes us part of the way, let’s add a layer of style. Here, I’m using custom css variables and css grid to make this module completely responsive.

.toggle-set --toggle-card-size: clamp(100px, 100%, 200px) display: grid grid-template: auto / repeat(auto-fit, minmax(var(--toggle-card-size), 1fr)) grid-gap: 1em margin: 0 auto padding: 0 border: none width: 100% .toggle-card position: relative background: transparent border-radius: 4px display: flex min-height: clamp(25px, 20vw, 180px)

Styling the Inputs

Next, let’s apply some custom styling to the actual input element itself to give it the appearance of our tiles. We’re going to use a pseudo-element here to lend another element for keyboard interaction that matches the parent element—in this case the checkbox—with an extra 3px padding.

.checkbox position: absolute top: 50% left: 50% transform: translate(-50%, -50%) width: 100% height: 100% border-radius: 4px background: transparent &::before content: "" position: absolute top: 50% left: 50% width: 100% height: 100% padding: 3px transform: translate(-50%, -50%) border-radius: 4px border: thin dotted transparent

Built-in Functionality

Now let’s repurpose the amazing functionality hidden in html checkboxes with css.

.myglow-flavor-notes__checkbox position: absolute top: 50% left: 50% transform: translate(-50%, -50%) width: 100% height: 100% border-radius: 4px background: transparent &::before content: "" position: absolute top: 50% left: 50% width: 100% height: 100% padding: 3px transform: translate(-50%, -50%) border-radius: 4px border: thin dotted transparent &:hover background: rgba(white, .1) &:active background: rgba(white, .5) &:checked background: white &:focus, &:focus-visible outline: none !important &::before outline: none border: none &:focus-visible border: thin solid $white &::before border: thin solid $white

As we continue styling, we turn to the label. For this element, we need it to respond to the checked state of the checkbox so that the label remains readable. To accomplish this, we check for the checked state and tie it to this element with a css combinator.

.label position: absolute top: 50% left: 50% transform: translate(-50%, -50%) user-select: none z-index: 2 .myglow-flavor-notes__checkbox:checked + & color: $blue

Introducing the Limitation

Now with a slick layer of style on a semantic html foundation, let’s discuss the user limitation further. This particular module calls for a limit of three checkboxes to be checked at all times. The easiest way to accomplish this might just be to prevent checking any more boxes after the third one is reached, but in my estimation this makes for a bad user experience.

In the case that a user is trying to check a fourth checkbox and is not able to, there might be a moment of frustration there that could be avoided with a little TLC. Also, to a screenreader user, there might not be any indication that the limit has been reached, and they could be completely unaware of that limit and missing context.

Interaction Goal

So our goal for this interaction would be something seamless and responsive to each user action, instead of ignoring anything that happens after the third check. For this solution, we will allow the user to continuously check as many checkboxes as they wish, but only preserve the three most recent selections at any point in the interaction.

This solution would thereby prevent any non-response from the UI, and keep the screenreader user updated on what is or is not selected with additional guidance from our legend and label elements.

Future-proofing

Another feature that we’ll build in to this module is a customization to change the checkbox limit quantity. More on that coming up.

Final HTML with Custom Data Attributes

First things first, let’s set up some custom data attributes to make our HTML easier to access. Here’s our final HTML:

<fieldset class="toggle-set" data-limit-checkbox="3"> <legend class="legend"> Check up to three flavors </legend> <div class="toggle-card"> <input class="checkbox" type="checkbox" name="mixed berries" id="checkbox-1" data-checkbox> <label class="label" for="checkbox-1">Mixed Berries</label> </div> <div class="toggle-card"> <input class="checkbox" type="checkbox" name="mixed berries" id="checkbox-2" data-checkbox> <label class="label" for="checkbox-2">Mixed Berries</label> </div> <div class="toggle-card"> <input class="checkbox" type="checkbox" name="mixed berries" id="checkbox-3" data-checkbox> <label class="label" for="checkbox-3">Mixed Berries</label> </div> <div class="toggle-card"> <input class="checkbox" type="checkbox" name="mixed berries" id="checkbox-4" data-checkbox> <label class="label" for="checkbox-4">Mixed Berries</label> </div> </fieldset>

Moving to Javascript

Let’s gather all instances of our modules into an array and assign some variables to each element in each module. We’ll also create an instance of an empty array per module to load in user selections later.

Finally, we’ll fetch the limit cap that is set in our html file. This could also be controlled by a content management system like CraftCMS when converting our module into a php template.

const limitCheckboxes = [...document.querySelectorAll("[data-limit-checkbox]")] limitCheckboxes.forEach((el) => { const checkboxes = [...el.querySelectorAll("[data-checkbox]")] const limitCap = el.getAttribute("data-limit-checkbox") const selected = []

Array Methods

Next, we’ll add a simple click event listener to each checkbox. Inside that event listener, we’ll test for if the input is checked. If it is not checked, add it to the “selected” array for that particular module with the pop() method. If it is checked, take it out of the “selected” array with the push() method.

This is another strength of using semantic HTML in that we can just ask for the “checked” attribute instead of adding bloat.

checkboxes.forEach((checkbox) => { checkbox.addEventListener("click", () => { if (checkbox.checked === true ) { selected.push(checkbox) } else if (checkbox.checked === false ) { selected.pop(checkbox) } }) })

Finally, let’s bring it home by applying our limit that we fetched earlier from our HTML.

limitCheckboxes.forEach((el) => { el.addEventListener("click", () => { if (selected.length > limitCap) { selected[0].checked = false selected.splice(0, 1) } }) })

How it works

What we’re doing here is first testing the length of the “selected” array against the “limitCap”. If it’s greater that the limit, then uncheck the oldest checkbox in that array and use the splice() method.

Note that here, we’re not replacing anything into the array, and so we just need the start parameter (index of [0]), and the delete count parameter (1).

Final Javascript

const limitCheckboxes = [...document.querySelectorAll("[data-limit-checkbox]")] limitCheckboxes.forEach((el) => { const checkboxes = [...el.querySelectorAll("[data-checkbox]")] const limitCap = el.getAttribute("data-limit-checkbox") const selected = [] checkboxes.forEach((checkbox) => { checkbox.addEventListener("click", () => { if (checkbox.checked === true ) { selected.push(checkbox) } else if (checkbox.checked === false ) { selected.pop(checkbox) } }) }) el.addEventListener("click", () => { if (selected.length > limitCap) { selected[0].checked = false selected.splice(0, 1) } }) })

Final CSS

Finally, we’ll add a final layer of polish to our interactions by adding transitions and dimension into our css. Here’s our final css below:

.toggle-set --toggle-card-size: clamp(100px, 100%, 200px) display: grid grid-template: auto / repeat(auto-fit, minmax(var(--toggle-card-size), 1fr)) grid-gap: 1em margin: 0 auto padding: 0 border: none width: 100% .legend @extend %visuallyhidden .toggle-card position: relative background: transparent border-radius: 4px display: flex min-height: clamp(25px, 20vw, 180px) .checkbox position: absolute top: 50% left: 50% transform: translate(-50%, -50%) width: 100% height: 100% border-radius: 4px background: transparent transition: background 500ms ease, box-shadow 100ms ease box-shadow: inset 0px 0px 0px gray &::before content: "" position: absolute top: 50% left: 50% width: 100% height: 100% padding: 3px transform: translate(-50%, -50%) border-radius: 8px border: thin dotted transparent transition: border-color 100ms ease &:hover background: rgba(white, .1) &:active background: rgba(white, .5) box-shadow: inset 0px 0px 6px gray &:checked background: white &:focus, &:focus-visible outline: none &::before outline: none border: none &:focus-visible border: thin solid white &::before border: thin solid white .label position: absolute top: 50% left: 50% transform: translate(-50%, -50%) user-select: none z-index: 2 transition: color 500ms ease .checkbox:checked + & color: blue

Final Result and Discussion

Check out the solution in the Codepen below. Starting with semantic HTML made our end result all the more navigable and interactive for sighted and non-sighted users. Further, it saved us a lot of headache by accessing checked states in CSS and Javascript.

Our consideration for the user experience paid off in a smooth and seamless checkbox limit, as did our hover, click, and keyboard styles.

See the Pen Styled Limit Checkbox by Cody (@codyhopper) on CodePen.

Overall, we got a pretty nice module going here that's easily reproducible and plays friendly with content management systems and copies of the same module on the page.

If you like this module or see an error, let me know through the contact page. Thank you for reading!