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 usinghtmlFor
or a uniqueid
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 statusdisabled
boolean for the toggle's disabled conditionlabel
string for text labelariaLabel
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 thechecked
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 betweenrect
andcircle
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
orvisibility: 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
andlarge
versions of the toggle
If you have any feedback or questions, don't hesitate to contact me.