Skip to main content Accessibility Feedback

Creating a toggle switch with just CSS

Today, I wanted to quickly share how I built Kelp’s toggle switch component with a single HTML attribute and CSS (no JavaScript required).

Let’s dig in!

The [role="switch"] attribute

The [role="switch"] attribute is functionality identical to [role="checkbox"] (the implicit role a [type="checkbox"] element has), except that it conveys an on/off state instead of checked/unchecked.

<label for="agree">
	<input 
		type="checkbox"
		id="agree"
	>
	I'm a standard checkbox
</label>

<label for="switch">
	<input 
		type="checkbox"
		id="switch"
		role="switch"
	>
	I'm a switch
</label>

On it’s own, it changes nothing about how a checkbox looks or behaves.

We can hook into it with CSS to style our switch differently.

It already provides the semantics we need for screen readers, and checkboxes already have the keyboard interaction behaviors we want.

Styling a switch

You can style attributes the same way you’d style elements, IDs, and classes by wrapping your selector in square brackets ([]).

Let’s start by styling our base “off” switch.

First, we need to remove the default browser styles, which we can do with appearance: none. We’ll also add display: inline-block so that we can style it as a block element while keeping it inline with the flow.

[role="switch"] {
	appearance: none;
	display: inline-block;
}

We want our switch to be a bit wider than it is tall. We’re going to use those values a few times, so we’ll save them to CSS variables.

We can use our --height outright. We’ll use the --width as a ratio relative to the --height.

The CSS calc() function (yes, CSS is a programming language and has functions) to add 1 to our --width and then multiply it by the --height.

This creates a 1.8:1 ratio of width:height.

[role="switch"] {
	--height: 1.1875em;
	--width: 0.8;

	appearance: none;
	display: inline-block;
	height: var(--height);
	width: calc(var(--height) * calc(1 + var(--width)));
}

Finally, let’s add a few visual details.

We’ll remove the border, and set a border-radius of 99em to give the edges a curved or “pill” appearance.

[role="switch"] {
	--height: 1.1875em;
	--width: 0.8;

	appearance: none;
	display: inline-block;
	background-color: #e5e5e5;
	height: var(--height);
	width: calc(var(--height) * calc(1 + var(--width)));
	border: 0;
	border-radius: 99em;
}

Aligning things

For all of this to work properly, we also need to set box-sizing to border-box.

As a general rule, I like to apply that to everything in my designs, but you could apply it to just the [role="switch"] if you wanted.

/**
 * Add box sizing to everything
 * @link http://www.paulirish.com/2012/box-sizing-border-box-ftw/
 */
*,
*:before,
*:after {
	box-sizing: border-box;
}

We’ll also use the :has() CSS pseudo-class to check if our label has a [role="switch"] checkbox in it.

If so, we’ll apply display: inline-flex to our label so that we can center the toggle switch and label text, and give it a small gap.

label:has([role="switch"]) {
	display: inline-flex;
	align-items: center;
	gap: 0.25em;
}

Here’s what we’ve got so far.

A light gray oval with the text 'I am a switch' next to it

It doesn’t look like much, but we’ll get there soon!

Adding the actual toggle switch

The reason this doesn’t look like much of anything is because it doesn’t have the actual toggle switch yet.

We’ll add that using the ::after pseudo-element. The most important parts here are to assign:

  1. A content property with an empty string ("") so that it’s actually displayed.
  2. An aspect-ratio of 1, so that toggle has the same height and width.
  3. A border-radius of 50% to make it round.
  4. A height of 100% so it fills the height of the toggle.
/* thumb */
[role="switch"]::after {
	content: "";
	display: block;
	aspect-ratio: 1;
	height: 100%;
	border-radius: 50%;
}

When switches are poorly styled, it can be difficult to tell when they’re “on” or “off.”

To make it nice and clear, we’ll use a very muted color with dark border in the “off” position: white with dark gray. I’m using the max() function to use 2px or 0.125em for the border width, whichever is larger.

[role="switch"]::after {
	content: "";
	display: block;
	aspect-ratio: 1;
	background-color: white;
	border: max(2px, 0.125em) solid #808080;
	height: 100%;
	border-radius: 50%;
}

In the “on” position, we’ll make the toggle a lot more vivid.

Here’s what we’ve got now.

A light gray oval with a white toggle switch that has a gray border

Now, this is visibly a toggle. Let’s add the “on”/“off” state!

Toggle switch on/off state

Under-the-hood, our switch is a checkbox. That means it has the :checked state when it’s “on.”

We’ll add a bright blue background to the :checked state for the [role="switch"] element. We’ll also use that same color for the ::after pseudo-element (the toggle) border-color.

[role="switch"]:checked {
	background-color: #0088cc;
}

[role="switch"]:checked::after {
	border-color: #0088cc;
}

We also want the toggle switch to slide over to the right. We can do that by adding a translate property.

We’ll use calc() to get the get the width of our toggle, but we’ll leave out the + 1 to account for the size of our toggle switch. Then, we’ll slide the toggle over by that much.

[role="switch"]:checked::after {
	border-color: #0088cc;
	translate: calc(var(--height) * var(--width)) 0;
}

Now, toggling it “on” and “off” will change the color and move the toggle.

An oval toggle switch with bright blue background

Adding a subtle animation

This is totally optional, but we can animate the toggle slide and background color with CSS, too.

We’ll add a transition to the background-color on our [role="switch"] element.

[role="switch"] {
	/* ... */
	transition: background-color 100ms ease-in-out;
}

And we’ll add a transition to the translate on the toggle itself.

[role="switch"]::after {
	/* ... */
	transition: translate 100ms ease-in-out;
}

Now, the switch slides when you toggle it.

Accessibility considerations

The technical accessibility considerations are taken care of with [role="switch"] and style choices.

But how and when you use a toggle over a regular checkbox is important, too. The intent here is to convey on/off state.

Do not use this component for things like accepting terms of service or selecting for a list of items. Do use it for turning features or settings on or off.

The label text should also make it clear that you’re turning something on/off.

<label for="dark-mode">
	<input role="switch" id="dark-mode">
	Enable Dark Mode
</label>

And the label text should be on the “on” side for the switch.

Check out Kelp!

If you want components like this and many more in an extremely customizable and lightweight UI library, checkout Kelp UI.

Kelp is a UI library for people who love HTML, powered by modern CSS and Web Components. It’s written with vanilla CSS and JavaScript, and is built to be easily customized with no build steps.