A highly configurable switch component using modern CSS techniques

Learn how build a highly configurable switch component using modern CSS, such as :has(), container queries, Logical Properties and Custom Properties.


Safari Technology preview has recently added a native switch component with version 185 and 186, which is great! It’s going to be a long while before this is ready to rock on a production website though.

Still, this collection of demos is worth enjoying. Here’s a video for those who don’t have the latest version of Safari Technology Preview.

Native switches that are seemingly highly customisable with CSS in Safari Technology preview.

While we wait for native switch support, I thought I would build a highly configurable switch component using :has(), container queries, Logical Properties and Custom Properties for fun and to show you how much goes into a truly flexible component. Let’s dig in.

What we’re building permalink

See the Pen Our final switch component by Andy Bell (@piccalilli) on CodePen.

HTML first, always permalink

The HTML for this is pretty straightforward:

Code language
html
<label class="switch-input">
  <span class="visually-hidden">Enable setting</span>
  <input type="checkbox" role="switch" class="visually-hidden" />
  <span class="switch-input__decor" data-switch-input-state="on" aria-hidden="true"
    >On</span
  >
  <span class="switch-input__decor" data-switch-input-state="off" aria-hidden="true"
    >Off</span
  >
  <span class="switch-input__thumb" aria-hidden="true"></span>
</label>

The first thing to note is the root of this component is a <label> element. I like that pattern for checkbox and radio buttons because you get a nice increased tap area.

The HTML form control is a checkbox, but I’ve added a role="switch" attribute to it. This is so the component is announced as a switch by a screen reader and also each state change is announced as “on” or “off”, which is appropriate for a switch in my opinion.

I could have used an aria-label for the text label (always add a text label, pals), but I opted instead for a visually hidden <span>. The main reason is it’s easier for a user’s in-browser translation tool to translate the content.

Lastly, there’s 3 decorative only elements. The “on” and “off” text are hidden from assistive tech with aria-hidden="true" because that tech is already announcing those states. The visual thumb of the control is hidden in the same manner too because it only provides value for sighted users.

Configuration settings permalink

As promised, this thing is configurable. There’s no better way in native CSS than Custom Properties.

Code language
css
:root {
  --switch-input-thumb-size: 44px;
  --switch-input-thumb-bg: #ffffff;
  --switch-input-thumb-stroke: 1px solid grey;
  --switch-input-off-bg: #444444;
  --switch-input-off-text: #ffffff;
  --switch-input-on-bg: #00a878;
  --switch-input-on-text: #ffffff;
  --switch-input-gutter: 4px;
  --switch-input-decor-space: var(--switch-input-gutter) 1.25ch;
  --switch-input-focus-stroke: 2px solid #ff6978;
  --switch-input-font-weight: bold;
  --switch-input-font-family: sans-serif;
  --switch-input-font-size: 18cqw;
  --switch-input-transition: inset 50ms linear;
}

I’m not going to go into too much detail at this point. I’ll explain stuff as we come to it in the component’s CSS. One thing I will note though is that the pixel sizes are there for reasons like:

  1. I wanted to hit the WCAG minimum tap target size
  2. The thumb size and thumb gutter are used in calculations, so clamp() is outa the window

Visually hidden utility permalink

Code language
css
.visually-hidden {
  border: 0;
  clip: rect(0 0 0 0);
  height: auto;
  margin: 0;
  overflow: hidden;
  padding: 0;
  position: absolute;
  width: 1px;
  white-space: nowrap;
}

I roll this variant of screen reader only CSS out on pretty much every project and have done so for quite some time. This will allow a screen reader to read the label’s content, but will visually hide it and also prevent it from affecting layout etc.

The .switch-input root permalink

It’s time to get stuck into the component now.

Code language
css
.switch-input {
  width: calc((var(--switch-input-thumb-size) * 2) + (var(--switch-input-gutter) * 3));
  height: calc(var(--switch-input-thumb-size) + (var(--switch-input-gutter) * 2));
  border-radius: calc(var(--switch-input-thumb-size) + var(--switch-input-gutter));
  padding: var(--switch-input-gutter);
  background: var(--switch-input-off-bg);
  color: var(--switch-input-off-text);
  text-align: left;
  text-transform: uppercase;
  font-family: var(--switch-input-font-family);
  font-weight: var(--switch-input-font-weight);
  position: relative;
  cursor: pointer;
  container-type: inline-size;
}

The first calculation is the width of the component itself because it is also the switch “track”. The two configuration options used are --switch-input-thumb-size and --switch-input-thumb-gutter. The thumb size is self-explanatory, but the gutter is the space around the thumb.

The finished switch component in its off state

The gutter provides space around the thumb, so we need to account for each side of the overall .switch-input element and also added space for the middle. The formula for the calculation is thumbSize x 2 added to gutter x 3.

The border-radius uses the following formula for relative rounded corners: radius + padding. It’s not as visible in this context as say, a rounded rectangle, but if you have a large gutter it will make a difference.

There’s a lot of inheritable CSS in here such as font treatments, so we’ll skip that. The last bit I want to focus on is the container-type: inline-size. This will be used later to calculate the label’s size, so it’s critical that it is present here because that size will be relative to the track’s inline size. We also roll out position: relative because everything is absolutely positioned from here on in.

The decorative text elements permalink

Code language
css
.switch-input__decor {
  position: absolute;
  inset-block: 0;
  inset-inline-start: 0;
  padding: var(--switch-input-decor-space);
  font-size: var(--switch-input-font-size);
  display: flex;
  width: 100%;
  align-items: center;
}

We’re using the logical versions of inset here because it’ll be handy for this component to respond the HTML dir attribute. For example, if a parent HTML has dir="rtl", it’ll automatically present as rtl.

It’s worth noting that the inset shorthand property is not logical. It is shorthand for top, right, bottom and left. That’s why we’re using the specific logical versions.

You can also see that we’re applying our font-size here, rather than inheriting from the parent. This is because the --switch-input-font-size Custom Property is using cqw units: a portion of the container’s computed width.

As the thumb sizes changes, the whole component, including the text changes with it. As the padding increases, the need for a relative border radius for the parent component is demonstrated too.

As that demo shows, if the component grows, the labels grow with it nicely. Please use this approach with caution though. You need to make sure that the text is large enough (ideally computed to at least 16px) and that it also zooms appropriately. With the switch input’s overall size being pixel controlled in this example, and the fact we’re using 44px — the minimum tap target size — that is the case.

Future stuff: align-content

A nice improvement to this decorative text’s rule would be the following (don’t add this to your code):

Code language
css
align-content: center;
display: block;
block-size: 100%;

We’re only using flexbox to vertically align our text labels in the middle. This new alignment capability is arriving to block elements in the future which should render those already completely out of date, vertical alignment CSS memes, useless, once and for all. I imagine this won’t change the social media clout chaser’s behaviour though, unfortunately.

Code language
css
.switch-input__decor[data-switch-input-state='off'] {
  justify-content: flex-end;
}

Lastly for this element, we’re adding a CUBE Exception to push the “off” label out to the inline end because the thumb will be at the inline start in the default off state.

The thumb element permalink

Time to style up our little round thumb element.

Code language
css
.switch-input__thumb {
  display: block;
  width: var(--switch-input-thumb-size);
  height: var(--switch-input-thumb-size);
  border-radius: var(--switch-input-thumb-size);
  background: var(--switch-input-thumb-bg);
  border: var(--switch-input-thumb-stroke);
  z-index: 1;
  position: absolute;
  inset-block-start: var(--switch-input-gutter);
  inset-inline-start: var(--switch-input-gutter);
  transition: var(--switch-input-transition);
}

We’re making a square by setting the width and height, then making that square a circle by using the same configuration value for border-radius. You could use percentages here, but I personally prefer this approach. I think it’s a symptom of the bad old days of browsers.

It’s important to set z-index because we want this to always be a layer up from our decorative labels and guarantees that the thumb won’t interfere with the visual text.

Finally, using absolute positioning, the thumb is set to the inline and block start, using the logical inset values. A transition (very quick one!) is added to smooth out the on and off state changes. Normally I would recommend that you don’t transition inset because translate is much smoother, but in this context, it should be fine because it’s not a large surface area and the transition is very quick (100ms).

Focus states permalink

The important thing to get right here for me is that the focus ring should show for keyboard users only and not when the component is clicked or tapped.

Code language
css
.switch-input:has(:focus-visible) .switch-input__thumb {
  outline: var(--switch-input-focus-stroke);
}

Luckily :focus-visible does this well and is very well supported.

The party trick here is the usage of :has(). Historically with this sort of component you’d have to write a selector like this: .switch-input input:focus-visible ~ .switch-input__thumb.

This would require the source order to be just right in the component. Now that we have :has()which is also very well supported — that state can be determined at the root level of the component. In theory, the input could be the last child element now.

The on/off states permalink

This is the last bit of CSS and we are done!

Code language
css
.switch-input:has(:checked) {
  background: var(--switch-input-on-bg);
  color: var(--switch-input-on-text);
}

.switch-input:has(:checked) .switch-input__thumb {
  inset-inline-start: calc(
    var(--switch-input-thumb-size) + (var(--switch-input-gutter) * 2)
  );
}

Historically setting the background of the switch component would again, require some weird combination selectors and extra elements, or require a JavaScript dependency. Not anymore because :has() allows us to change the background and text colour of the component (if required) based on its child :checked state. Handy!

We also use that pattern to change the position of the thumb. It’s a calculation that similar to the one we added earlier to set the size of the overall component. This time, it’s pushing the thumb to the end when the switch is “on”. This is why the --switch-input-gutter is doubled, to account for the start space and also the middle space.

Wrapping up permalink

After all that, we’ve got ourselves a lovely little component:

See the Pen Our final switch component by Andy Bell (@piccalilli) on CodePen.

Would I use this in production? Probably, yeh. There has to be a damn good reason for a switch in the first place though.

Regardless, this is a handy little context to teach you about some of the super powers CSS gives us now.