Solution: Toggle switch

Front-End Challenges Club - Solution #002

This is the solution to Challenge #002. Let’s dive straight in!

In terms of progressive enhancement, we’ve not got much to worry about with this one because if the CSS and JS that this switch doesn’t load, it has no role, so it’ll be hidden. We’ll cover that later in the solution.

This solution has the following code files:

  • index.html, referred to as “your markup” for the rest of this tutorial
  • css/global.css, referred to as “your CSS” for the rest of this tutorial
  • js/main.js, referred to as “your JS” for the rest of this tutorial

You can see a live demo of the final solution here.

HTML permalink

Let’s first add the base HTML structure to your empty index.html file:

Code language
<!DOCTYPE html>
<html lang="en">
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <meta http-equiv="X-UA-Compatible" content="ie=edge" />
    <title>Front-End Challenges Club - 002</title>
    <link rel="stylesheet" href="" />
    <link rel="stylesheet" href="/css/global.css" />

  <script src="/js/main.js" defer></script>

This is our core structure and as you can see, it’s pretty simple this time around. We’re pulling in our CSS and JS, along with a CDN version of my modern reset that I use.

With this now in place: inside the <main> element, add the following markup:

Code language
<label class="toggle" for="toggle-element">
  <span class="toggle__label">Dark mode</span>
  <input type="checkbox" role="switch" class="toggle__element" id="toggle-element" />
  <div class="toggle__decor" aria-hidden="true">
    <div class="toggle__thumb"></div>
    <svg class="toggle__icon" data-state="checked" focusable="false" version="1.1" xmlns="" width="1em" height="1em" viewBox="0 0 32 32">
      <path d="M24.633 22.184c-8.188 0-14.82-6.637-14.82-14.82 0-2.695 0.773-5.188 2.031-7.363-6.824 1.968-11.844 8.187-11.844 15.644 0 9.031 7.32 16.355 16.352 16.355 7.457 0 13.68-5.023 15.648-11.844-2.18 1.254-4.672 2.028-7.367 2.028z"></path>
    <svg class="toggle__icon" data-state="un-checked" focusable="false" version="1.1" xmlns="" width="1em" height="1em" viewBox="0 0 32 32">
      <path d="M16.001 8c-4.418 0-8 3.582-8 8s3.582 8 8 8c4.418 0 7.999-3.582 7.999-8s-3.581-8-7.999-8v0zM14 2c0-1.105 0.895-2 2-2s2 0.895 2 2c0 1.105-0.895 2-2 2s-2-0.895-2-2zM4 6c0-1.105 0.895-2 2-2s2 0.895 2 2c0 1.105-0.895 2-2 2s-2-0.895-2-2zM2 14c1.105 0 2 0.895 2 2 0 1.107-0.895 2-2 2s-2-0.893-2-2c0-1.105 0.895-2 2-2zM4 26c0-1.105 0.895-2 2-2s2 0.895 2 2c0 1.105-0.895 2-2 2s-2-0.895-2-2zM14 30c0-1.109 0.895-2 2-2 1.108 0 2 0.891 2 2 0 1.102-0.892 2-2 2-1.105 0-2-0.898-2-2zM24 26c0-1.105 0.895-2 2-2s2 0.895 2 2c0 1.105-0.895 2-2 2s-2-0.895-2-2zM30 18c-1.104 0-2-0.896-2-2 0-1.107 0.896-2 2-2s2 0.893 2 2c0 1.104-0.896 2-2 2zM24 6c0-1.105 0.895-2 2-2s2 0.895 2 2c0 1.105-0.895 2-2 2s-2-0.895-2-2z"></path>

That’s all of our HTML! But I wont sell you short: let’s dig in to some detail:

We wrap the entire component in a <label> which gives us a very generous click and tap target. We still have a for attribute which links to the checkbox though.

The checkbox has a role of switch which announces mostly as on/off rather than checked/unchecked. It’s a really handy role for our context because there’s a definite on or off state and no indeterminate state. Use a switch role with care. They can be problematic, so here’s some resources:

We have a toggle__decor element which houses all of the fancy stuff, so, because that only presents visual value, we hide it from assistive technology with aria-hidden="true". Remember: the element that we interact with is always a checkbox, so we’re not being harmful by doing this.

CSS permalink

Now that we have our markup in order, let’s dive into the styles. Add this to your CSS:

Code language
@font-face {
  font-family: 'Lato';
  src: url('/fonts/Lato-Black.woff2') format('woff2');
  font-weight: 900;
  font-style: normal;
  font-display: swap;

:root {
  --color-day-bg: #0984e3;
  --color-day-icon: #ffe4a1;
  --color-night-bg: #032b43;
  --color-night-icon: #b9c6d3;
  --color-light: #ffffff;
  --color-dark: #4a4a4a;
  --color-charcoal: #252525;
  --color-shadow-light: rgba(48, 48, 48, 0.15);
  --color-shadow-mid: rgba(0, 0, 0, 0.25);
  --color-shadow-dark: rgba(0, 0, 0, 0.9);
  --font-base-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen-Sans,
    Ubuntu, Cantarell, 'Helvetica Neue', sans-serif;
  --font-label-family: 'Lato', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen-Sans,
    Ubuntu, Cantarell, 'Helvetica Neue', sans-serif;
  --metric-toggle-thumb-size: 1.4rem;
  --metric-toggle-thumb-space: 0.25rem;
  --metric-toggle-icon-space: 0.4rem;
  --transition-snappy-duration: 500ms;
  --transition-silky-duration: 200ms;
  --transition-icon: opacity var(--transition-snappy-duration) ease, transform var(
      ) cubic-bezier(1, 0, 0.55, 0.85);
  --transition-delay-icon: 1000ms;

We’ve added a @font-face declaration, so make sure that you’ve added the font file to /fonts in your project.

After that, there is a wall of CSS Custom Properties. This sort of context is prime for a heavily tokenised setup because so many parts work in harmony with each other. Also, I’m a stickler for consistency, as you’ll learn over the course of a few challenges.

A slight aside at this point is notice that I only use cubic-bezier timing functions for movement transitions. The curve that I use here gives a lovely bit of realism to the movement. You can create your own with this handy generator.

Right, let’s add some global styles. Add this to your CSS:

Code language
body {
  background: var(--color-light);
  color: var(--color-charcoal);
  font-family: var(--font-base-family);
  display: grid;
  place-items: center;

We’re keeping things super simple here and just like in Solution #001, we’re using a fancy CSS Grid trick to place items in the center of the viewport.

Let’s start fleshing out our .toggle element. Add this to your  CSS:

Code language
.toggle {
  font-family: var(--font-label-family);
  line-height: 1;
  font-weight: 900;
  text-transform: uppercase;
  position: relative;

.toggle:not([hidden]) {
  display: flex;
  align-items: center;

This is our base of the component. You’ll spot that I add flex to the container only when it’s not hidden. This will become clear later on in the solution. For now, it’s a handy specificity trick to get you a little boost.

Next add this to your CSS:

Code language
.toggle__element {
  opacity: 0;
  position: absolute;
  top: 50%;
  transform: translateY(-50%);
  right: var(--metric-toggle-thumb-size);
  width: 1em;
  height: 1em;

The toggle__element is our checkbox. Notice, instead of using the typical .visually-hidden utility class, we’re hiding it by setting the opacity to 0 and using position: absolute to center it vertically and sit it on top of the upcoming decorative switch. This approach helps with discoverability on certain devices and I strongly recommend that you watch this talk by the legendary Sara Soueidan where I learned this.

Right, let’s add some more toggle styles. Add this to your CSS:

Code language
.toggle__decor {
  --color-toggle-decor-bg: var(--color-day-bg);

  display: block;
  position: relative;
  overflow: hidden;
  width: calc(
    (var(--metric-toggle-thumb-size) * 2) + (var(--metric-toggle-thumb-space) * 2)
  height: calc(var(--metric-toggle-thumb-size) + (var(--metric-toggle-thumb-space) * 2));
  background: var(--color-toggle-decor-bg);
  margin-left: 0.5rem;
  border-radius: var(--metric-toggle-thumb-size);
  transition: background var(--transition-snappy-duration);
  transition-delay: var(--transition-snappy-duration);
  box-sizing: content-box;
  border: 1px solid var(--color-light);

This is the container of our decorative switch. It’s all pretty self-explanatory, but a couple of bits to note:

  • I’m setting the background with a scoped custom property: --color-toggle-decor-bg. This means that in state changes, I can just update that, rather than setting the background property again.
  • I’m setting box-sizing: content-box; because I want my border to push out, rather than be accounted for with dimensions that are set. I wrote about this stuff here.
  • Notice how I’m setting metrics relative to --metric-toggle-thumb-size and --metric-toggle-thumb-space. This helps to simplify our state changes and generally creates are more harmonious user interface.

Next up, let’s add our switch’s thumb. Add the following to your CSS:

Code language
.toggle__thumb {
  display: grid;
  place-items: center;
  width: var(--metric-toggle-thumb-size);
  height: var(--metric-toggle-thumb-size);
  border-radius: var(--metric-toggle-thumb-size);
  background: linear-gradient(
      rgba(255, 255, 255, 0) 14.29%,
      rgba(48, 48, 48, 0.15) 81.82%
    ), #ffffff;
  color: var(--color-day-icon);
  box-shadow: 0 0 var(--metric-toggle-thumb-space) var(--color-shadow-mid);
  position: absolute;
  top: var(--metric-toggle-thumb-space);
  left: var(--metric-toggle-thumb-space);
  transform: none;
  transition: transform var(--transition-silky-duration) cubic-bezier(1, 0, 0.55, 0.85);
  will-change: transform;
  z-index: 1;

.toggle__thumb::before {
  content: '';
  display: none;
  width: calc(var(--metric-toggle-thumb-size) - var(--metric-toggle-thumb-space));
  height: calc(var(--metric-toggle-thumb-size) - var(--metric-toggle-thumb-space));
  border: 2px solid var(--color-dark);
  border-radius: calc(var(--metric-toggle-thumb-size) - var(--metric-toggle-thumb-space));

“Why the hell are you using grid here, Andy?”, I hear them scream. It’s because it’s a handy way to center child elements!

As you can see, I’m setting the styles with the consistent Custom Properties here, too. I’m also adding a will-change: transform property, which gives the browser a little heads up that this thing will very likely change. This can have a positive impact on repaints.

After this, I set a ::before pseudo-element which will be our focus style. This is why I used grid on the thumb itself 😉. As you can see, it’s hidden by default and we’ll toggle that as the checkbox is focused, later on.

Right, the last bit of static, visual CSS, is the icons. Add the following to your CSS:

Code language
.toggle__icon {
  position: absolute;
  top: 50%;
  transform: translateY(-50%);
  font-size: 0.9em;

.toggle__icon[data-state='checked'] {
  left: var(--metric-toggle-icon-space);
  fill: var(--color-night-icon);
  opacity: 0;
  transform: translateY(-1rem);

.toggle__icon[data-state='un-checked'] {
  right: var(--metric-toggle-icon-space);
  fill: var(--color-day-icon);
  transition: var(--transition-icon);
  transition-delay: var(--transition-delay-icon);

What we’re doing here is vertically centering and sizing the icons together, then branching of into [data-state] selectors to set colours and horizontal positioning. The [data-state="checked"] state has the transform overridden because by default, it’s hidden. We also want it to transition downwards when it comes into view, which is why we use a negative rem value.

Let’s add our interactive CSS:

Code language
.toggle__decor:hover .toggle__thumb {
  margin-left: 1px;

:checked + .toggle__decor:hover .toggle__thumb {
  margin-left: -1px;

Here’s a little interactive trick here for you. We nudge the thumb in the direction of the next state with these blocks. This means that if you’re in the off state, it’ll nudge to the right. It’s super subtle, but super useful.

Next, add the :checked state:

Code language
:checked + .toggle__decor {
  --color-toggle-decor-bg: var(--color-night-bg);

:checked + .toggle__decor .toggle__thumb {
  transform: translateX(var(--metric-toggle-thumb-size)) rotate(270deg);
  box-shadow: 0 0 var(--metric-toggle-thumb-space) var(--color-shadow-dark);

:checked + .toggle__decor .toggle__icon[data-state='checked'] {
  opacity: 1;
  transform: translateY(-50%);
  transition: var(--transition-icon);
  transition-delay: var(--transition-delay-icon);

:checked + .toggle__decor .toggle__icon[data-state='un-checked'] {
  opacity: 0;
  transform: translateY(1rem);
  transition: none;

Let’s break it down:

  1. The first thing we do is set that --color-toggle-decor-bg.
  2. We shift the thumb using transform. This is a super performant way of doing CSS transitions because transform doesn’t affect layout, thus the browser doesn’t have to make expensive calculations about surrounding elements. The browser will often use the GPU to perform the transition too, which means they are super silky.
  3. We switch the icons around so the moon drops in. Now, when we un-check, the sun with “rise”. Fancy, huh?

And that’s it. Nice and clean! Let’s wrap up the CSS with a bit of focus and disabled states:

Code language
:focus + .toggle__decor:not(:hover) .toggle__thumb::before {
  display: block;

:disabled + .toggle__decor {
  filter: grayscale(1) brightness(1.5);
  cursor: not-allowed;

When we focus, but aren’t hovering the thumb: we show the focus ring we added earlier.

When the whole thing is disabled, we’re just making it grayscale and adding a handy not-allowed cursor.

That’s it for CSS!

JavaScript permalink

We’ve only got a tiny bit of JavaScript to add. Add this to your JS:

Code language
const toggle = document.querySelector('.toggle');


What’s happening here is that we’re removing a hidden attribute. We need to add that, so open up your markup and modify your <label class="toggle"> element so it looks like this:

Code language
<label class="toggle" for="toggle-element" hidden></label>

With this in place: when JS isn’t available, your toggle will be hidden. This is great because when you apply actual functionality for your site, it’ll be with JS, so if the JS isn’t available, this component is pointless, so it’s best to be hidden.

Even though we’re not going into the further functionality of a dark mode switch, let’s add a bit of JS for fun, to flip the body colours by toggling a class. Add this to your JS:

Code language
toggle.querySelector('input').addEventListener('change', evt => {
  if ( {


What we’re doing here is listening to our checkbox’s change event. We access the checkbox with which returns the element that this event has happened on.

Checkboxes give us a true/false representation of their checked state, so we add or remove a dark class on the <body> accordingly.

Now, let’s finish up by adding some CSS for this effect:

Code language
body.dark {
  background: var(--color-charcoal);
  color: var(--color-light);

Now, if you refresh your browser, it should toggle the colours!

If you want some guidance on how to approach a dark theme responsibly, I’ve got your back.

One further tweak you could do is add rtl support, which you can learn about in this Adrian Roselli post.

Wrapping up permalink

I really hope you’ve enjoyed this tutorial. You can grab a zip of the final code that I wrote and also see a live version too!

My favourite attempt at this was by James Bateson. Check out their Twitter thread. The transitions are lush and you get to see a decent approach using a <button> and aria-pressed instead.

If you liked this post, you might like these ones, too

  1. Challenge: Email sign-up form

    Front-End Challenges Club - Challenge #001

  2. Challenge: Heading Keyline

    Front-End Challenges Club - Challenge #007

  3. Solution: Heading Keyline

    Front-End Challenges Club - Challenge #007


Become a supporter by joining the Piccalilli Membership

For as little a $5 per month, you can get access to a private, friendly community, get a weekly newsletter and help to make as much content free as possible around here. If you join the $10 per month supporters club, you get access to premium tutorials and free access to mini courses!

Become a member

Sign up for updates

Stay up to date with updates from Piccalilli. You’ll get alerted as soon as any new content gets published. You’ll also get updates on upcoming courses and membership features! You can unsubscribe at any time, too.