How to create a custom radio switch in CSS

In this tutorial, we'll take a look at how to create a custom radio switch and how to keep it accessible.

We recently published the Radio Switch component. It's not particularly tricky to implement, but some points need to be kept in mind to make sure it remains accessible.

Let's do this! #

Here's a video tutorial explaining how to create the custom radio switch. Feel free to skip the video if you prefer to read the article.

The component we build in this tutorial is based on the CodyHouse framework.

👋 First time you hear about the CodyHouse Framework?

The basic idea is: we create a list of two radio inputs (the two available options); we then visually hide those inputs and apply the style to the visible labels.

Here's the HTML structure:

<ul class="radio-switch">
  <li class="radio-switch__item">
    <input type="radio" class="radio-switch__input sr-only" id="radio1" name="radioSwitch" checked>
    <label for="radio1" class="radio-switch__label">Monthly</label>
  </li>

  <li class="radio-switch__item">
    <input type="radio" class="radio-switch__input sr-only" id="radio2" name="radioSwitch">
    <label for="radio2" class="radio-switch__label">Yearly</label>
  </li>
</ul>

We have added the sr-only class to both input elements to visually hide them while they are still accessible to the Screen Readers. You can read more about this class on our Accessibility documentation page.

Now we can add some style to the label elements (so that they are aligned and have the same width) and to the .radio-switch element:

:root {
  // style
  --radio-switch-width: 186px;
  --radio-switch-height: 46px;
  --radio-switch-padding: 3px;
  --radio-switch-radius: 50em;

  // animation
  --radio-switch-animation-duration: 0.3s;
}

.radio-switch {
  display: inline-flex;
  padding: var(--radio-switch-padding);
  border-radius: var(--radio-switch-radius);
  border: 1px solid var(--color-contrast-low);
}

.radio-switch__item {
  height: calc(var(--radio-switch-height) - 2*var(--radio-switch-padding));
  width: calc(var(--radio-switch-width)*0.5 - var(--radio-switch-padding));
}

.radio-switch__label {
  display: block;
  line-height: calc(var(--radio-switch-height) - 2*var(--radio-switch-padding));
  text-align: center;
  border-radius: var(--radio-switch-radius);
}

We need a marker that can be used as a background element moving from one side to the other of the switch when a new radio input is checked. We can include this marker inside the second list item element:

<ul class="radio-switch">
  <li class="radio-switch__item">
    <!-- input + label --> 
  </li>

  <li class="radio-switch__item">
    <!-- input + label --> 
    <div class="radio-switch__marker" aria-hidden="true"></div>
  </li>
</ul>

We have added an aria-hidden true to hide the element from screen readers.

We can now style this element, making sure it translates when a new input is checked:

.radio-switch__item {
  position: relative;
}

.radio-switch__marker {
  position: absolute;
  top: 0;
  left: -100%;
  height: 100%;
  width: 100%;
  background-color: var(--color-primary);
  border-radius: var(--radio-switch-radius); 
  transition: transform var(--radio-switch-animation-duration);

  .radio-switch__input:checked ~ & { // translate the marker from one side to the other
    transform: translateX(100%);
  }
}

The one bit missing now is the focus style: we need to make sure users navigating the page using the keyboard have a visual hint about the switch element being in focus.

Fo that we can use the :focus-within pseudo-class:

.radio-switch {
  // ...

  &:focus-within {
    box-shadow: 0 0 0 3px var(--color-contrast-lower);
  }
}

When the checked radio input is in focus (focus is within the .radio-switch element), a box-shadow is added to the switch to signal it is focused.

⚠️ Note: the :focus-within pseudo-class is not supported in all modern browser at the time of writing. 

To fix this, we can add an alternative focus effect (not based on the :focus-within class) and then overwrite it for browsers that support :focus-within (using this CSS trick):

.radio-switch {
  // ...

  &:focus-within {
    box-shadow: 0 0 0 3px var(--color-contrast-lower);
  }
}

.radio-switch__label {
  //..

  .radio-switch__input:focus ~ & { 
    // focus effect in browsers not supporting :focus-within
    background-color: lightness(var(--color-primary), 0.6);
  }

  :not(*):focus-within, // trick to detect :focus-within support -> https://css-tricks.com/using-feature-detection-conditionals-and-groups-with-selectors/
  .radio-switch__input:focus ~ & { 
    // reset focus style for browsers supporting :focus-within
    background-color: transparent;
  }
}

That's it! The custom radio switch is now ready to be used.

Feedbacks/suggestions? Get in touch on Twitter.