How to Build a Reusable and Accessible Toggle Switch With Vue.js
By: Thomas Lombart
Tags: Programming, Vuejs, JavaScript, Vue, Software Development

Props and V-Model

We need to pass two props to the Switch: label , which is a string, and checked , which is a boolean. Remember that the checked prop comes from v-model:checked :

<template>

<label>

<input

type="checkbox"

:checked="checked"

@change="$emit('update:checked', $event.target.checked)"

/>

<span></span>

<span>{{ label }}</span>

</label>

</template> <script>

export default {

name: "Switch",

props: {

label: {

type: String,

required: true,

},

checked: {

type: Boolean,

required: true,

},

},

}

</script>

There’s a problem with the input above. Indeed, the root element is different than the underlying input. We have to create an additional prop for any other attribute we want to pass to the input ( disabled for example).

To fix it, we need to put a v-bind="$attrs" on the input and disable attribute inheritance on the root element:

<template>

<label>

<input

v-bind="$attrs"

type="checkbox"

:checked="checked"

@change="$emit('update:checked', $event.target.checked)"

/>

<span></span>

<span>{{ label }}</span>

</label>

</template> <script>

export default {

name: "Switch",

inheritAttrs: false,

/* ... */

}

</script>

Style the Component

The container and the label

So far, our switch should look something like this:

Let’s face it: it’s ugly as hell. To make it prettier, we’ll add CSS classes to the different elements:

<template>

<label class="container">

<input

v-bind="$attrs"

class="input"

type="checkbox"

:checked="checked"

@change="$emit('update:checked', $event.target.checked)"

/>

<span class="switch"></span>

<span class="label">{{ label }}</span>

</label>

</template>

We’ll do them one by one. First, the .container . We know the text will be on the right of the switch, and we want it to be perfectly centered. We also want the whole toggle to be clickable, so let's add a pointer cursor to it:

<style scoped>

.container {

cursor: pointer;

display: flex;

align-items: center;

}

</style>

We also need to give a prettier color to the label and give some space from the checkbox:

.label {

margin-left: 12px;

color: #1a202c;

}

Then, though we use the <input> for semantic reasons, it won't be useful to us in terms of visuals. We need to hide it visually but still keep it in the DOM for accessibility reasons:

/* Visually hide the checkbox input */

.input {

position: absolute;

width: 1px;

height: 1px;

padding: 0;

margin: -1px;

overflow: hidden;

clip: rect(0, 0, 0, 0);

white-space: nowrap;

border-width: 0;

}

Note: the properties are inspired by the .sr-only class from Tailwind CSS.

The switch

The switch UI we’re building

As you can see, the switch is composed of a rounded container element with a circle inside of it that moves to the left or to the right depending on whether the input is checked or not.

If you look at the screenshot, you can see that the inner circle is roughly half the container element size. The container width has twice the size of its own height. Let’s make use of CSS custom properties for that:

.switch {

--switch-container-width: 50px;

--switch-size: calc(var(--switch-container-width) / 2);

}

To create the inner circle, we’re going to use the ::before element trick. To make it inside the container, we'll need to give the container a relative position and the inner circle an absolute position.

Additionally, the inner circle should be nearly the size of --switch-size , but it shouldn't overflow the container. We'll use the function to adjust it:

.switch {

--switch-container-width: 50px;

--switch-size: calc(var(--switch-container-width) / 2);

/* Vertically center the inner circle */

display: flex;

align-items: center;

position: relative;

height: var(--switch-size);

flex-basis: var(--switch-container-width);

/* Make the container element rounded */

border-radius: var(--switch-size);

background-color: #e2e8f0;

} .switch::before {

content: "";

position: absolute;

/* Move a little bit the inner circle to the right */

left: 1px;

height: calc(var(--switch-size) - 4px);

width: calc(var(--switch-size) - 4px);

/* Make the inner circle fully rounded */

border-radius: 9999px;

background-color: white;

}

Here is the result:

That’s nice, but if you click on the switch, nothing happens. At least, not visually. Indeed, the input is checked correctly, but your switch isn’t connected to it!

To reflect these changes, you’ll need to use the CSS adjacent sibling selector to style the switch according to the different input states. For example, when your checkbox is checked, the pseudo-class is added. Let's make use of that then:

.input:checked + .switch {

/* Teal background */

background-color: #4fd1c5;

} .input:checked + .switch::before {

border-color: #4fd1c5;

/* Move the inner circle to the right */

transform: translateX(

calc(var(--switch-container-width) - var(--switch-size))

);

}

The way the switch moves from one state to another isn’t smooth. We need to add transitions to transform and background-color to fix it:

.switch {

/* ... */

transition: background-color 0.25s ease-in-out;

} .switch::before {

/* ... */

transition: transform 0.375s ease-in-out;

}

The focus and disabled states

Right now, you should have a switch that works. But the work isn’t entirely done yet! Indeed, there are still different states to an input that we haven’t implemented here. For example, if you press the Tab key to focus the switch, you don't have any visual feedback that it's properly focused. The same goes for disabled inputs.

As a first step, we will add additional CSS custom properties to .switch and replace the hardcoded colors:

.switch {

/* ... */

--light-gray: #e2e8f0;

--gray: #cbd5e0;

--dark-gray: #a0aec0;

--teal: #4fd1c5;

--dark-teal: #319795;

/* ... */

background-color: var(--light-gray);

} .input:checked + .switch {

background-color: var(--teal);

} .input:checked + .switch::before {

border-color: var(--teal);

/* ... */

}

Note: The colors come from Tailwind CSS, in case you’re wondering.

Let’s tackle the focus state. We’re not going to do anything complicated UI-wise, we’ll have to add a border on the inner circle:

.switch::before {

/* ... */

border: 2px solid var(--light-gray);

}

Here, we chose the same color as the background of the switch container. Indeed, initially, we want the inner circle border color to confound with the background color. That way, when we are adding a different border-color for the focus state, we'll see it. We're going to add a darker border-color when the input is focused:

.input:focus + .switch::before {

border-color: var(--dark-gray);

} .input:focus:checked + .switch::before {

border-color: var(--dark-teal);

}

Here’s how it looks:

For the disabled state, we will fill the inner circle with gray and make the switch container darker to indicate that you can’t do anything on it:

.input:disabled + .switch {

background-color: var(--gray);

} .input:disabled + .switch::before {

background-color: var(--dark-gray);

border-color: var(--dark-gray);

}

Here’s what our disabled switch looks like:

The switch’s responsiveness

We have one last thing to check. Look at the screenshot below:

If you have a long label, you can see that the text overflows on the switch and may take multiple lines. That’s not responsive. Let’s make sure our switch can’t shrink, and the label doesn’t take more than one line:

.switch {

/* ... */

/* In case the label gets long, the toggle shouldn't shrink. */

flex-shrink: 0;

} .label {

/* ... */

/* Show an ellipsis if the text takes more than one line */

overflow: hidden;

text-overflow: ellipsis;

white-space: nowrap;

}

Now, our switch is responsive:

Voilà! We’re done for the switch. The switch is a small piece of UI, but it’s not that easy to implement. Here are the main takeaways when building such a component:

Think of your component’s API beforehand and its use cases. You’ll have a better vision of what you want to build.

Don’t forget to think of the different constraints of a UI component: its accessibility, its various states, its responsiveness, etc.

Build your component step by step. You should have an iterative approach with these kinds of components.

In case you want to have the full code, I put it in a GitHub gist, how nice is that? 😄