Skip to main content
Photography of Alvaro Montoro being a doofus
Alvaro Montoro

Tech Writer

A switch next to the text Creating a CSS-Only Toggle Switch. Picture by Isaac Li Shung Tan on Unsplash

Creating a CSS-Only Toggle Switch

html css webdev tutorial a11y

There are many articles online about how to create a switch using HTML and CSS only, without any JavaScript. How will this one be different? What value will it add to the existing articles? Why did I write it?

Many articles about this topic are "outdated" or implement old-school solutions (even the most recently published ones!) Also, most overlook essential parts of web development, such as web accessibility or usability. Finally, I wanted to go beyond the "what" or the "how" and focus on the "why." All this while keeping a neutral and realistic tone: I like CSS but don't want to sound like a fanboy who thinks CSS is almighty.

Hopefully, I can achieve these goals, and you will enjoy the article. Or at least you find it insightful.

Checkbox vs. Switch

When using CSS to create a toggle switch, a checkbox seems like the obvious choice, and I will not debate that: it has a state, the browser handles the logic, and all we need is styling the checkbox (or its label, more on that soon.)

While all those statements are correct, it is essential to emphasize one key point: a switch is not a checkbox. If they were the same, we wouldn't even have articles like this! Checkboxes and toggle switches look different, behave differently, and have different values.

Number and type of states

A switch has two possible states: on and off. "On" when it is active, and "off" when it is not. Meanwhile, a checkbox has three states: checked, unchecked, and indeterminate. From an HTML perspective, we tend to forget about the indeterminate state because it is more visual, and we need JavaScript to set it dynamically.

three checkboxes in checked, unchecked and indeterminate state
Player 3 entered the game.

A priori, this third state will not impact our implementation. Still, it would be relevant to specify in the documentation that developers must not set the checkbox to this indeterminate state via code. Unfortunately, there will be no way to enforce this requirement without adding JavaScript to the mix, and it may break how the switch works. So, adding a note in the documentation and instructions is our only option.

Side effect actions

The most significant difference between a checkbox and a switch is that the latter should perform an action when it is activated or deactivated. So, for example, if we use the component to define some setting, changing its state will trigger a direct change in the app or website.

We must remember that a switch's full name is "toggle switch" or "toggle button," which makes it more evident that it is a button and expects a side effect when interacting with it.

If this switch was a JavaScript component, we could show error messages if there wasn't a callback function associated with the value change or prevent the indeterminate state. It might not be perfect, but it would be something. Being a static component, we must provide good-practice guidelines and documentation and trust in the developers' good judgment.

The HTML code

Most online tutorials opt for combining two tags to generate a switch. An HTML structure that looks like a variation of this:

<input type="checkbox" class="switch" id="my-switch" />
<label for="my-switch">Label text</label>

Then, the input is hidden, and the label's pseudo-elements help draw the toggle switch and its two states. Simple, yet prone to errors and issues:

  • It is a rigid structure: swapping the order of the elements will ultimately break the switch. Also, even the slightest change in visualization (e.g., moving the toggle switch from left to right) will require additional changes in the CSS.
  • It tends to be non-accessible: so many things could go wrong and yield an inaccessible component (which happens in most tutorials). The two main offenders: hiding the checkbox in a way that is not accessible via the keyboard and not handling focus correctly.

But not all is bad with that implementation! The input + label approach has a great benefit: it has a label. According to the WebAIM One Million report, missing form labels are one of the most common web accessibility issues plaguing websites nowadays. All inputs should have an accessible name, and this implementation forces users to have one, which is "nice."

We are going to do something more straightforward:

<input type="checkbox" role="switch" />

Notice that I didn't add a label tag, but it doesn't mean we don't need it. As mentioned a couple of paragraphs above, all inputs must have an accessible name. The difference is that, in our case, it is not a requirement. Something that gives us much-needed flexibility and allows us to structure the HTML code in different ways without requiring any CSS change:

<!-- label before the input -->
<label for="ms1">My switch</label>
<input type="checkbox" role="switch" id="ms1" />

<!-- label after the input -->
<input type="checkbox" role="switch" id="ms2" />
<label for="ms2">My switch</label>

<!-- label wrapping the input -->
<!-- Warning: some ATs may have trouble reading this -->
<label>
  <input type="checkbox" role="switch" />
  <span>My switch</span>
</label>
Author's Note: for simplicity, I will continue the article focusing exclusively on the input that will generate the toggle switch. The label is important and must be added to our code, but its styling is independent of the styling of the checkbox, so it will be left out from this article.

We are achieving three different goals by using role="switch":

  • We provide a distinction between a checkbox and a toggle switch: at this point, humans may not be able to tell them apart (without styling, they both will look like checkboxes), but we have changed the component semantically, and machines will detect the difference, which gives way to the next two.
  • Assistive technologies (AT) identify the checkbox as a switch: this is important because not only will they announce a toggle switch when they reach our component, but they will also read the value as "on" and "off" instead of "checked" and "unchecked."
  • We provide a selection mechanism: now that we can differentiate between a checkbox and a toggle switch, we can also select them differently in CSS! We can offer different styles to checkboxes (input[type="checkbox"]) and switches (input[role="switch"]). More on that in the next section.

That's it—a single HTML element. A checkbox turned into a switch. Later, when we place the component on the page, we will add a label, but it will be on our terms: because we want to and it is the right thing, not because we have to.

Another reason to go with this option: a checkbox natively has a lot of functionality that we get to keep by using this approach but that we will lose if we opt for an input + label solution. And that's the root cause of most problems with that implementation:

  • Checkboxes get focused; labels do not. Therefore, the hiding of the input must be accessible, letting the checkbox get the focus when navigating with the keyboard. Otherwise, ATs won't be able to announce the structure properly.
  • Checkboxes get outlined; labels do not. If you go with the double tag solution, you will need to consider the focus on the input and highlight the associated title accordingly. Something like input:focus + label in the CSS to provide specific focus styles. Messy, messy.
  • Checkboxes get printed; backgrounds do not (by default). This issue may be petty, but hear me out. People still print from the web, and they print forms. But unfortunately, one of the most common mistakes with custom components is that they do not print correctly. And in the case of radio buttons, checkboxes, or toggle switches, that is a big problem.

By keeping the component simple, we will have more control over it while leveraging all the features that the browsers directly provide. In addition, no "hacky" styles needed on the label mean more flexibility for styling. Our next step will be on this because, until now, our toggle switch still looks exactly like a checkbox.

an unchecked checkbox
Our switch so far is just another checkbox

The CSS code

With the HTML the way it is, our toggle switch still looks like a checkbox. But that won't be the case soon. And all thanks to appearance. With this property, we can control the visualization (and appearance) of native UI controls like inputs, text areas, buttons, etc.

In particular, we are going to set the appearance of the checkbox to none. That way, it will stop looking like a checkbox altogether, and we will be able to style it freely.

But before that, let's see something of extreme importance: the selector. We have added a role attribute, and it has a type of "checkbox," so we could have a selector with two attributes:

input[type="checkbox"][role="switch"] {
  /* this will select the checkboxes with switch role */
}

But this rule has a problem: the attribute selector has the same specificity weight as a class, so selecting using two would carry a weight higher than a single class. We want to provide styling flexibility (always within certain limits) to the people using our component; forcing them to use !important o triple classes for styling the toggle switch should not be considered reasonable.

There are two options to go around this inconvenience: using the :is() or :where() pseudo-classes, and preferably :where() because it carries zero specificity. So our selector will "only have a tag" and allows easy customization by adding a class to the element:

input:where([type="checkbox"][role="switch"]) {
  /* styles */
}

Unfortunately, the support for the :where() Pseudo-class is not as extended (especially on mobile browsers), and we may want to consider avoiding it, even if it will inconvenience developers later. I will keep using :where() as part of this article because I believe it should be the way forward.

The initial style for the toggle switch would be:

input:where([type="checkbox"][role="switch"]) {
  -webkit-appearance: none;
  -moz-appearance: none;
  appearance: none;
  position: relative;
  font-size: inherit;
  width: 2em;
  height: 1em;
  box-sizing: content-box;
  border: 1px solid;
  border-radius: 1em;
  vertical-align: text-bottom;
  margin: auto;
  color: inherit;
}

Let's analyze the properties little by little:

  -webkit-appearance: none;
  -moz-appearance: none;
  appearance: none;

With these properties, we are resetting the appearance. As I said above, this will hide the main features of the checkbox and give us more freedom for styling. I added the vendor prefixes because, although the appearance property is well supported now, that was not the case not too long ago (especially on mobile). Therefore, having the vendor prefixes will ensure the success of this crucial part.

But the most significant advantage of using appearance: none is that, even when the checkbox doesn't look like a checkbox anymore, the browsers will still handle the main interactions a checkbox has: focus, outline, checked state, etc., which is great for accessibility. The input+label approach loses these features that need to be manually coded by the developers, or they are lost, causing accessibility trouble.

  position: relative;

This property is more for setting the stage later. We will use a pseudo-element to indicate if the switch is off or on and position it within the parent based on that value. That's why we need the position: relative. We could go without the pseudo-element, displaying the state using a background, but we want to cover one typical case people miss when creating custom UI controls: printing. If we specify the state with a radial background, printers will ignore it unless the user checks some settings. We will use a pseudo-element to avoid creating this inconvenience for the user.

  font-size: inherit;
  width: 2em;
  height: 1em;

Next, we set a width double the element's height to get the characteristic shape of a toggle switch. You may have noticed that the units are in em, not on pixels or rem. The em unit helps make the toggle switch responsive, so it adapts to the size of the text around it (that's why we set the font-size: inherit.) Our component would look weird if the font size were big, but the toggle switch looked tiny. Using em solves this problem, as the following graphic shows:

four toggle buttons of different sizes next to the text 1em, 2em, 3em, and 4em
Toggle switch along with different text sizes
  box-sizing: content-box;
  border: 1px solid;
  border-radius: 1em;

These properties create the outside shape of the toggle switch. There are some exciting things in these three lines of code: the box-sizing value is "content-box," which is the default value. Why? Because many developers tend to add blanket statements * { box-sizing: border-box; } to their CSS, that would cause problems with the visualization of our toggle switch. So, we rest it back to content-box (even when it may not be necessary) to be on the safe side.

The border-radius is set to em while the border width is in pixels, another curious choice. If we use em, the browser may round the value to less than one, depending on the font size, which could result in a toggle without a border, which would be an accessibility violation (as it wouldn't be apparent where the UI control begins or ends.)

Also, you may have noticed that the border doesn't have a color. By default, the border color will be the same as the text color, so we can leave it empty for now-more on that in a few lines.

  vertical-align: text-bottom;

This property is an opinionated choice on my side. Without specifying the vertical alignment, the toggle switch will align differently depending on the content next to it. And that may lead to inconsistent and ugly results (see below.) Setting a vertical alignment of "text-bottom" will make things look nicer, but it is not needed for our implementation—more of a personal choice.

Comparison of switch boxes display with and without vertical-align
One of these looks better than the other
  margin: auto;
  color: inherit;

These two properties reset a couple of styles that differ among browsers. Sometimes the checkbox has a margin we want to remove for the toggle. And we want to set the color of the parent, so if the color of the text changes, so will the toggle button. Again, these are opinionated choices, but the good thing is that the way we created the selector, they can quickly be overrun by the user.


With the CSS code, we get the skeleton of the toggle switch but no indication of if it is on or off. We will do that with the ::before pseudo-element.

Empty toggle switch
It looks more like a toggle switch now, but something is missing

We could do much more. For example, most browsers have a different style when a UI control is active. I initially had a short code that faded the switch when it was in an active state:

input:where([type="checkbox"][role="switch"]):active {
  opacity: 0.6;
}

Unfortunately, the native behavior is inconsistent across browsers: Chrome will fade the control, Firefox will make it darker, and Safari will apply a darker background. We could try to mimic that, but it would be a pain (and targeting browsers is not a good idea in general), and it could produce "unexpected" behavior. We want to avoid any confusion for the users. Not only for accessibility reasons but also general usability. So I ended up removing that code. But don't let that prevent you from doing something like that on your implementation (especially if it's part of a design system and the behavior across the components is similar.)

The ::before pseudo-element

We use this pseudo-element to generate the circle that will indicate the on (on the right) or off state (on the left). I will not get into the weeds of animation or making it look pretty in this article. We want the toggle switch to be functional and accessible while limiting it to HTML and CSS. I will leave the creative part to the developers and designers out there.

The following code styles the ::before pseudo-element:

input:where([type="checkbox"][role="switch"])::before {
  content: "";
  position: absolute;
  top: 50%;
  left: 0;
  transform: translate(0, -50%);
  box-sizing: border-box;
  width: 0.7em;
  height: 0.7em;
  margin: 0 0.15em;
  border: 1px solid;
  border-radius: 50%;
  background: currentcolor;
}

Let's see what and why we use these properties as with the previous code.

  content: "";

No pseudo-element is visible without content, so having empty content is the least we need to do. We could go one step beyond and make the content "off" when the checkbox is not checked and "on" when it is. But that would require more styling, and it could break accessibility (small, clipped, or unreadable text) without providing much gain in return. So, let's leave it as it is.

  position: absolute;
  top: 50%;
  left: 0;
  transform: translate(0, -50%);

All these properties are "just" to place the pseudo-element in the initial position (unchecked). Because the parent has a position: relative this position: absolute will be within the parent. It will be centered vertically and to the left.

  box-sizing: border-box;
  width: 0.7em;
  height: 0.7em;
  margin: 0 0.15em;

In the same way that we added a box-sizing of "content-box" in the parent, we need to specify a value of "border-box" for the pseudo-element. We want the element's width to include the content's width, the padding (none), and the border.

I could have used aspect-ratio: 1; but chose not to because, although I'm a big fan of that property, mobile browsers don't support it extensively, and in this case, it's not a big issue or a complex calculation to get the height (it will be the same as the width.)

The values are in the em unit to match the parent's size. And they are not "magic numbers": the width is 0.7, and the margin is 0.15 horizontally. So the element will occupy 1em, which is precisely half the toggle switch container size. For the checked styles, we'd only need to translate or place the pseudo-element 1em to the right.

  background: currentcolor;

The background will be the same color as the text. Again, a personal choice that is not necessary for the toggle switch. I find it easier to see the button this way. We don't need it, but I prefer it this way.

  border: 1px solid;
  border-radius: 50%;

The border again has no color, so that it will use the text color by default. And you may ask, "why do we need a border if we are already setting up the background?" First of all, great question. Second, for accessibility reasons. The browser removes backgrounds when printing or in high-contrast mode; by adding a border, we ensure that the pseudo-element will be visible in those cases, thus making the component accessible to everyone.

a toggle switch
The toggle switch is almost complete.

And we must not forget the checked status! If the toggle switch is on, the pseudo-element will move to the other side:

input:where([type="checkbox"][role="switch"]):checked::before {
  left: 1em;
}
two toggle switches, one off, the other one off
The toggle switch can display on and off states now.

With this, our toggle switch code is over. The complete CSS code for the toggle switch is as follows:

input:where([type="checkbox"][role="switch"]) {
  -webkit-appearance: none;
  -moz-appearance: none;
  appearance: none;
  position: relative;
  font-size: inherit;
  width: 2em;
  height: 1em;
  box-sizing: content-box;
  border: 1px solid;
  border-radius: 1em;
  vertical-align: text-bottom;
  margin: auto;
  color: inherit;
}

input:where([type="checkbox"][role="switch"])::before {
  content: "";
  position: absolute;
  top: 50%;
  left: 0;
  transform: translate(0, -50%);
  box-sizing: border-box;
  width: 0.7em;
  height: 0.7em;
  margin: 0 0.15em;
  border: 1px solid;
  border-radius: 50%;
  background: currentcolor;
}

input:where([type="checkbox"][role="switch"]):checked::before {
  left: 1em;
}

And here you can see a demo of this UI control in action:

It looks simple, but that was one of the goals. Now it can be easily customizable and make it more dynamic and beautiful. And because of the code choices along the way, the structural code should not affect the styling code added by the developer. Give it a try.

Conclusion

We have been peppering the article with accessibility comments in different sections. Still, I feel it is essential to add one last (but not least) note about accessibility (among other topics).

While it is possible to create a toggle switch with HTML and CSS only, some things are still out of reach for those two languages and would require a little JavaScript. It would be the cherry on top, what brings this component from good to great. For example, it would be ideal to have an aria-checked attribute that updates when the switch is turned on or off (it looks like modern screen readers announce the switch states correctly, but we never know what the user will be using.)

Also, this implementation runs on an "honor system." As mentioned in a previous section, a switch has two states, while a checkbox has three. There is nothing in place to prevent developers from manually changing the checkbox value to indeterminate or not performing an action when the switch changes state. We must trust the developers will do the right thing —which, unfortunately, is rarely the case—otherwise, they may break accessibility.

The solution for many of these implementation issues: use JavaScript. Let's face it, HTML and CSS are powerful, but they are limited languages for some things. A progressive enhancement approach could be great with the initial HTML+CSS described above and minimal JS complementing it. No need for bloated HTML, CSS, or JavaScript. Let the browser do the job for you!

The toggle switch looks a bit bare, but that's by design. You can add animations and cool drawings to the pseudo-element. Make it your own! And when you do, don't forget to share it in a comment! I love seeing the demos that people create.

Article originally published on