Build an Accessible Toggle Switch with React and SVG

A working example of this project can be found at this codepen.

Overview

I wanted a toggle switch that would match this site's visual style and render consistently across browsers, and I wanted to reuse it easily in other parts of my site.

Most browsers use native OS inputs or draw their own custom controls, and I've never enjoyed the process of restyling form controls with CSS. That's why I decided to start from scratch with SVG.

I also wanted the switch to be accessible, responsive to keyboard navigation, and accurately reflect focus, checked, and diabled states. To that end, I started with a standard checkbox control and used CSS to hide it and bind the control's states to the SVG. In addition to preserving accessibility, this approach meant that I didn't need to keep track of any state within my component.

An example of the switch in use can be seen in the header of this site. It toggles a vertical rhythm grid, and it's implemented like this (minus the label, which I don't use in this context):

<Toggle
  checked 
  label="Toggle Grid"
  ariaLabel="Toggle vertical rhythm grid" 
  handleChange={toggleGrid}
/>

SVG? In a Form Control?

There are well-worn techniques for restyling inputs with CSS. So why introduce SVG into the mix, especially for a component that could be easily drawn with CSS?

It's a personal preference. I've never loved using CSS for drawing because, as anyone who has ever tried to draw a triangle with CSS knows, it has its limitations.

SVG is designed for drawing, so why not use it? It certainly adds complexity in the form of additional DOM elements, but on the other hand it offers far more opportunity for customization.

I think it's more fun, too.

What About Accessibility?

This custom control preserves the keyboard navigation, focus, aria-label of of a standard checkbox because it's directly coupled with a standard checkbox control.

Let's Build It!

Start with a Input Component

To get started, let's create a basic functional component with an associated stylesheet.

To ensure that our custom control works as much like a standard input as possible, we'll use a standard input behind the scenes:

import React from 'react';
import './toggle.css';

const Toggle = () => {
  return (
    <label className="toggle">
      <input type="checkbox" />
    </label>
  );
};

export default Toggle;

Note that we put the input inside the <label> tag. This creates an implicit association between the input and label and lets us avoid using htmlFor or a unique id in case we have multiple controls on a page, while still allowing us to toggle the input by interacting with label.

Add Properties

There are a number of properties we'll need our <Toggle /> component to accept. Let's start with the most essential:

  • checked boolean for the toggle's checked status
  • disabled boolean for the toggle's disabled condition
  • label string for text label
  • ariaLabel string for accessible screen reader description

Let's destructure those properties at the top of the function:

const Toggle = ({ checked, label, disabled }) => {

and render them:

<label className="toggle">
  <input
    type="checkbox"
    disabled={disabled}
    defaultChecked={checked}
    ariaLabel={ariaLabel}
  />
  <span className="text-label">
    {label}
  </span>
  ...

Note that we use the input's defaultChecked instead of the checked property. To learn more about why, read this article from the React team

We now have a checkbox that behaves identically to a native checkbox, but with a built-in label that we can interact with. We're already making our lives easier:

<Toggle label="Boring Checkbox"/>
<Toggle checked label="Boring Checked Checkbox"/>
<Toggle checked disabled label="Boring Disabled Checkbox"/>

Add Custom SVG

Let's add our custom SVG. The simplest possible slide toggle consists of a knob that moves horizontally within a channel or track. Let's start with two rectangles and round their corners with the rx attribute:

I wasn't sure at first whether I wanted rounded or rectangular shapes, and the rx attribute let me experiment without having to switch between rect and circle shapes.

return (
  <label className="toggle">
    <input
      type="checkbox"
      disabled={disabled}
      defaultChecked={checked}
      ariaLabel={ariaLabel}
    />
    <svg height="24px" viewBox="0,0 48,24">
      <rect
        className="channel"
        x="0"
        y="0"
        rx="12"
        width="48"
        height="24"
      />
      <rect
        className="knob"
        x="4"
        y="4"
        rx="8"
        width="16"
        height="16"
      />
    </svg>
    <span className="text-label">
      {label}
    </span>
  </label>
);

Hide the Input Element

Before hiding the input element, ensure that it gets toggled when you click or tap on your new SVG.

We can then hide the input with the following style:

input[type="checkbox"] {
  position: absolute;
  opacity: 0;
}

Note that we can't use display: none or visibility: hidden because doing so removes our ability to interact with the native input.

Style the SVG Shapes

I use custom CSS properties on my site, so my stylesheet looks something like this:

:root {
  --text-color: #24282d;
  --highlight-color: #D9D6D4;
  --pop-color: #D92B2B;
}

.channel {
  fill: var(--highlight-color);
}

.knob {
  fill: var(--text-color);
}

Checked Styles

We need a way for the checked condition of our native input to control the style of our knob element. Doing so is easy using the general sibling combinator in conjunction with the checked pseudo-class selector:

:checked ~ svg .knob {
  /* checked styles here */
}

This rule targets .knob elements that are descendants of svg elements that are themselves siblings of any element that has a checked attribute.

Change the color of the knob and move it 24px to the right:

:checked ~ svg .knob {
  fill: var(--pop-color);
  transform: translateX(24px);
}

The knob now moves and changes color when you click on it.

We want these changes to be animated, so update the basic .knob rule like so:

.knob {
  fill: var(--text-color);
  transition: transform .25s, fill .25s;
  transform-box: fill-box;
}

The transform-box: fill box; line may be unfamiliar to you. Sometimes SVG elements behave differently from standard HTML elements when applying transformations, and this rule helps them act like their HTML counterparts.

Disabled Styles

We can use a similar technique for the disabled state, targeting all siblings (in this case our SVG and the label):

:disabled ~ .text-label {
  opacity: .5;
}

Focus Highlight

There are a number of ways we could indicate when the toggle is the focused element. I chose to add a third shape to my SVG, an inset highlight:

<svg height="24px" viewBox="0,0 48,24">
  <rect
    className="channel"
    x="0"
    y="0"
    rx="12"
    width="48"
    height="24"
  />
  <rect
    className="focus-highlight"
    x="1"
    y="1"
    rx="11"
    width="46"
    height="22"
  />
  <rect
    className="knob"
    x="4"
    y="4"
    rx="8"
    width="16"
    height="16"
  />
</svg>

Make the highlight invisible in its non-focused state:

.focus-highlight {
  fill: none;
  stroke: none;
  stroke-width: 0;
  tansition: stroke, stroke-width;
}

And visible in its focused state:

:focus ~ svg .focus-highlight {
  stroke-width: 2;
  stroke: var(--pop-color);
}

Execute Callback on Toggle

The point of this whole exercise is to toggle something, so let's add a function to our component properties:

const Toggle = ({
  checked,
  label,
  disabled,
  handleChange
}) => {

And call that function when the input is toggled:

<input
  type="checkbox"
  disabled={disabled}
  defaultChecked={checked}
  ariaLabel={ariaLabel}
  handleChange={handleChange}
/>

Presto!

There you have it -- a totally custom, stateless, keyboard- and screen-reader accessible slide toggle backed by a native input.

You can see and play with a working example of this project at this codepen.

Next Steps

There are still lots of things we can do to make our shiny new toggle even shinier. Here are some ideas for improvements:

  • Incorporate the indeterminate state into the component.
  • Add size classes to make small and large versions of the toggle

If you have any feedback or questions, don't hesitate to contact me.