Checkboxes are great. Combine them with the right CSS and you can pull off some really neat tricks. This article aims to showcase some of the creative things you can do with checkboxes. Read on and keep in mind that the demos in this article use no JavaScript.

The Basic Formula

It all starts with the HTML.

<input id="toggle" type="checkbox">
<label for="toggle">

Nothing tricky there. The for attribute on <label> matches the id on <input>, so clicking on the <label> will toggle the <input> checkbox. This is important, because our next step is to hide <input>.

input {
  position: absolute;
  left: -9999px;
}

Why not display: none? Because that would cause it to be ignored by screen readers and keyboard tabbing. This method keeps <input> in the flow, but hides it offscreen.

Hiding <input> makes it easier for us to do our own thing. We still need to convey the checked/unchecked state, but we can do that with <label>. This is where the party starts.

input:checked + label {
  /* awesome styles */
}

We’re using a combination of the :checked pseudo-class and the + adjacent sibling selector to say “when the checkbox is checked, find the <label> right after it and apply awesome styles”. You can even use pseudo-elements (::before and ::after) within <label> for more creative freedom.

input:checked + label::before {
  /* styles for an "on" indicator */
}

Alright, let’s see it in action. This demo uses the basic formula we just discussed to turn regular checkboxes into something more impressive.

See the Pen Checkbox Trickery: Simple Toggle by Will Boyd (@lonekorean) on CodePen.

The best part is, they’re still checkboxes. Wrap them in a <form> and they’ll submit just like you’d expect. We’re changing appearances, but not behavior.

Hiding/Showing Content

So far it’s been all about styling <label>, but we can go beyond that. This demo dynamically hides/shows parts of a form based on user selection.

See the Pen Checkbox Trickery: Form Disclosure by Will Boyd (@lonekorean) on CodePen.

The :checked pseudo-class works on radio buttons the same as checkboxes. With that in mind, here’s the HTML for the “How did you hear about us?” radio buttons.

<input id="how-friend" name="how" type="radio">
<label for="how-friend" class="side-label">From a friend</label>

<input id="how-internet" name="how" type="radio">
<label for="how-internet" class="side-label">Somewhere on the internet</label>

<input id="how-other" name="how" type="radio">
<label for="how-other" class="side-label">Other...</label>

<div class="how-other-disclosure">
  <label for="how-other-explain" class="top-label">Please explain</label>
  <textarea id="how-other-explain"></textarea>
</div>

The radio button indicators are rendered within <label> using a combination of ::before (for the outer ring) and ::after (for the green dot). Showing/hiding ::after when a radio button is checked/unchecked is easy enough.

.side-label::after {
  display: none;

  /* other styles */
}

input:checked + .side-label::after {
  display: block;
}

The <div> is hidden until the radio button for “Other…” is checked. I hide the <div> with display: none, because this time I do want the content to be ignored by screen readers and keyboard tabbing until revealed. The CSS to reveal the <div> when the radio button is checked looks like this.

#how-other:checked ~ .how-other-disclosure {
  display: block;
}

We’ve been using the + adjacent sibling selector up until now, but this time we’re using the ~ general sibling selector. It’s similar, but can find non-adjacent siblings, like our <div>.

Folder Tree

We can reuse the techniques from the previous demo to create a folder tree widget, which has similar hide/show behavior.

See the Pen Checkbox Trickery: Folder Tree by Will Boyd (@lonekorean) on CodePen.

The HTML for a single folder is given below. The <label> is the folder and the two <a> elements are “files” within it.

<div>
  <input id="n-1" type="checkbox">
  <label for="n-1">Blue</label>
  <div class="sub">
    <a href="#link">Mana Leak</a>
    <a href="#link">Time Warp</a>
  </div>
</div>

Font Awesome icons are used to indicate checked (open) and unchecked (closed) states.

label::before, a::before {
  display: block;
  position: absolute;
  top: 6px;
  left: -25px;
  font-family: 'FontAwesome';
}

label::before {
  content: '\f07b'; /* closed folder */
}

input:checked + label::before {
  content: '\f07c'; /* open folder */
}

a::before {
  content: '\f068'; /* dash */
}

Content within a folder is toggled with a ~ general sibling selector. This is why there are extra <div> wrappers in the HTML, to keep this selector from “leaking out” and opening sibling folders.

input:checked ~ .sub {
  display: block;
}

Naturally, folders can be nested. Just drop the HTML for another folder into <div class="sub">. Click the “Multicolor” folder in the demo to see an example.

Lastly, let’s talk about that reset button.

<input type="reset" value="Collapse All">

Form reset buttons are rarely used anymore, but this is a decent case for one. Clicking it returns all checkboxes to their initial unchecked state, closing all folders. Neat.

Split List

This demo splits items into two separate lists depending on whether they’re checked done or not.

See the Pen Checkbox Trickery: To-Do List by Will Boyd (@lonekorean) on CodePen.

Update: Some people have pointed out that the convention is for checkboxes to be square, unlike what I’ve done in this demo. They’re not wrong.

The HTML looks like this.

<div class="items">
  <input id="item1" type="checkbox" checked>
  <label for="item1">Create a to-do list</label>

  <!-- more items -->

  <h2 class="done" aria-hidden="true">Done</h2>
  <h2 class="undone" aria-hidden="true">Not Done</h2>
</div>

The split list mechanic is achieved with CSS flexbox. Here’s the relevant CSS.

.items {
  display: flex;
  flex-direction: column;
}

.done {
  order: 1;
}

input:checked + label {
  order: 2;
}

.undone {
  order: 3;
}

label {
  order: 4;
}

CSS flexbox lets you directly rearrange elements with the order property. The order value of a <label> is changed from 4 to 2 when its checkbox is checked, moving it from below the “Not Done” <h2> to below the “Done” <h2>.

Unfortunately, keyboard navigation and many screen readers will follow the order of elements in the DOM, even if they’ve been visually reordered using CSS flexbox. This makes the “Done” and “Not Done” headers useless to screen readers, which is why I added aria-hidden="true" to them — better they be ignored than cause confusion. Besides that, the split list is still fully operable via keyboard and screen readers will still announce the state of an item (checked/unchecked).

If you’re curious about the counts next to “Done” and “Not Done”, those are generated with CSS counters. Check out this article if you want to learn more.

Group Filtering

Last demo. This one shows how to highlight a cross section of data that matches a selected criterion.

See the Pen Checkbox Trickery: Group Filter by Will Boyd (@lonekorean) on CodePen.

Here’s the abbreviated HTML. Notice how the data-teams attribute is a space-separated list of radio button id attributes. This is how we map characters to teams.

<input id="original" type="radio" name="team" checked>
<label for="original">Original X-Men</label>

<!-- more teams here -->

<br>
<ul class="characters">
  <li id="angel" data-teams="original force factor hellfire">
    <h2>Angel</h2>
    <img src="ct-angel.png" alt="">
  </li>

  <!-- more characters here -->
</ul>

Regarding accessibility, I use empty alt attributes because the character names are already in the <h2> tags — no point having each name read twice. Also, since I’m not actually hiding <img> elements (just shrinking and fading), this makes it easier to get screen readers to skip unhighlighted characters. I only need to hide the <h2>.

Here’s the CSS that highlights characters when their team is selected.

#original:checked ~ .characters [data-teams~="original"] h2,
#force:checked ~ .characters [data-teams~="force"] h2,
#factor:checked ~ .characters [data-teams~="factor"] h2,
#hellfire:checked ~ .characters [data-teams~="hellfire"] h2 {
  /* styles to show character name */
}

#original:checked ~ .characters [data-teams~="original"] img,
#force:checked ~ .characters [data-teams~="force"] img,
#factor:checked ~ .characters [data-teams~="factor"] img,
#hellfire:checked ~ .characters [data-teams~="hellfire"] img {
  /* styles to show character avatar */
}

I know these selectors look hairy, but they’re not so bad. Let’s dissect line 1 as an example. Talking it out, "when the element with an id of ‘original’ is checked, look within the sibling ‘characters’ element for anything with a data-teams attribute containing ‘original’, then find the <h2> within. Repeat for ‘force’, ‘factor’, and ‘hellfire’ on lines 2-4. Now do it all again on lines 8-11, but for <img> instead of <h2>.

Parting Words

I hope you had as much fun playing with these demos as I had making them. It was very interesting to me, seeing what I could pull off with something as modest as a checkbox. I have no qualms using JavaScript when appropriate, but it’s nice being able to accomplish so much without it. Thanks for reading!