Customization

Limit Checkboxes with Vanilla 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:

  • Creating a semantic structure of the selection gallery
  • Adding unique styling that makes it make sense
  • Implementing custom vanilla javascript to limit selection while improving the user experience.

Skip to 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 css, and it would be comprehensible to our javascript that we'll be needing in a little bit.

Layering Style

While semantic markup takes us part of the way, let’s add a layer of style. Use custom css variables and css grid to make this module 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, style the actual input element itself to give it a tile appearance. 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 extra 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.

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

  .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="tropical" id="checkbox-1" data-checkbox>
    <label class="label" for="checkbox-1">Tropical</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="mango" id="checkbox-3" data-checkbox>
    <label class="label" for="checkbox-3">Mango</label>
  </div>
  
  <div class="toggle-card">
    <input class="checkbox" type="checkbox" name="mint cucumber" id="checkbox-4" data-checkbox>
    <label class="label" for="checkbox-4">Mint Cucumber</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;
  }
}

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.

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!