Skip to content

Switches

A switch is an input widget that allows users to choose one of two values: on or off. Switches are similar to checkboxes and toggle buttons, which can also serve as binary inputs. One difference, however, is that switches can only be used for binary input.

The switch field has enough unique behaviors and use-cases that justify it having its own composable. The binary state of a switch means it shouldn’t be used to represent “required” inputs where the switch needs to be “on”. It is a user preference that can be turned off.

  • You can use an input[type="checkbox"] element as a base or any custom element.
  • Labeling, descriptions, and error message displays are automatically linked to input and label elements with aria-* attributes.
  • Support for custom on and off values.
  • Validation support with native HTML constraint validation or Standard Schema validation.
  • Support for v-model binding.
  • Supported keyboard features:
KeyDescription
Space
Toggles the switch on/off.
Enter
Toggles the switch on/off.
Input
Label
Label
Something went wrong
Error Message

The useSwitch composable provides the necessary props and methods to build a switch component. It returns binding objects for the elements shown in the anatomy. You will use v-bind to bind them to the corresponding DOM elements.

There are two ways to build a switch component:

  • With an input[type="checkbox"] as a base element.
  • Without an input element, like a div or a button.

We will review the two ways to build a switch component in the following examples.

We will add some styling to the next example because switches don’t look like one unless we style them. Otherwise, it would just look like a checkbox.

<script setup lang="ts">
import { ref } from 'vue';
import Switch from './Switch.vue';
const isOn = ref(false);
</script>
<template>
<Switch label="Theme" v-model="isOn" />
<div>Switch is: {{ isOn }}</div>
</template>
<script setup lang="ts">
import { useSwitch, type SwitchProps } from '@formwerk/core';
const props = defineProps<SwitchProps>();
const { inputProps, labelProps, isPressed, errorMessage, errorMessageProps } =
useSwitch(props);
</script>
<template>
<div class="switch">
<label v-bind="labelProps">
<div class="switch-control">
<div class="switch-knob"></div>
</div>
<input v-bind="inputProps" type="checkbox" class="sr-only" />
{{ label }}
</label>
<div v-bind="errorMessageProps" class="error">
{{ errorMessage }}
</div>
</div>
</template>
<style scoped>
.switch {
--switch-bg: #57534e;
--color-primary: #10b981;
--switch-readonly-bg: #a8a29e;
--switch-knob-color: #f5f5f4;
--color-text: #333;
--color-error: #f00;
--color-focus: var(--color-primary);
label {
display: inline-flex;
align-items: center;
cursor: pointer;
gap: 0.25rem;
font-size: 14px;
user-select: none;
}
.switch-control {
width: 3rem;
height: 1.5rem;
background-color: var(--switch-bg);
border-radius: 99999px;
transition: background-color 0.2s;
display: flex;
align-items: center;
}
.switch-knob {
border-radius: 99999px;
height: 1.2rem;
width: 1.4rem;
background: var(--switch-knob-color);
translate: 2px 0;
transition: translate 0.2s;
}
&:has(:checked) {
.switch-control {
background-color: var(--color-primary);
}
.switch-knob {
translate: 100% 0;
}
}
&:has(:focus) {
.switch-control {
outline: 2px solid var(--color-focus);
}
}
&:has([readonly='true']) {
.switch-control {
background-color: var(--switch-readonly-bg);
}
}
&:has(:disabled) {
.switch-control {
opacity: 0.4;
cursor: not-allowed;
}
}
/** This is a common utility CSS class, you can find it in your CSS framework of choice */
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
border: 0;
}
.error {
color: var(--color-error);
display: none;
font-size: 13px;
}
&:has(:user-invalid) {
.error {
display: block;
}
}
}
</style>

Similar to the previous example, we can achieve the same look and behavior with a div element or a button element.

In this example, we will use an svg element to closely resemble a switch. We will borrow those SVG paths from Phosphor Icons.

<script setup lang="ts">
import { ref } from 'vue';
import Switch from './Switch.vue';
const isOn = ref(false);
</script>
<template>
<Switch label="Theme" v-model="isOn" />
<div>Switch is: {{ isOn }}</div>
</template>
<script setup lang="ts">
import { useSwitch, type SwitchProps } from '@formwerk/core';
const props = defineProps<SwitchProps>();
const { inputProps, labelProps, isPressed, errorMessage, errorMessageProps } =
useSwitch(props);
</script>
<template>
<div class="switch">
<div class="switch-wrapper" v-bind="inputProps">
<div class="switch-control">
<div class="switch-knob"></div>
</div>
<div class="switch-label" v-bind="labelProps">{{ label }}</div>
</div>
<div v-bind="errorMessageProps" class="error">
{{ errorMessage }}
</div>
</div>
</template>
<style scoped>
.switch {
--switch-bg: #57534e;
--switch-active-bg: #10b981;
--switch-readonly-bg: #a8a29e;
--switch-knob-color: #f5f5f4;
--color-text: #333;
--color-error: #f00;
--color-primary: #10b981;
--color-focus: var(--color-primary);
.switch-label {
font-size: 14px;
user-select: none;
}
.switch-control {
width: 3rem;
height: 1.5rem;
background-color: var(--switch-bg);
border-radius: 99999px;
transition: background-color 0.2s;
display: flex;
align-items: center;
}
.switch-knob {
border-radius: 99999px;
height: 1.2rem;
width: 1.4rem;
background: var(--switch-knob-color);
translate: 2px 0;
transition: translate 0.2s;
}
[aria-checked='true'] {
.switch-control {
background-color: var(--switch-active-bg);
}
.switch-knob {
translate: 100% 0;
}
}
.switch-wrapper {
display: inline-flex;
align-items: center;
cursor: pointer;
gap: 0.25rem;
&:focus {
.switch-control {
outline: 2px solid var(--color-primary);
}
}
[readonly='true'] {
.switch-control {
background-color: var(--switch-readonly-bg);
}
}
[disabled='true'] {
.switch-control {
opacity: 0.4;
cursor: not-allowed;
}
}
}
/** This is a common utility CSS class, you can find it in your CSS framework of choice */
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
border: 0;
}
.error {
color: var(--color-error);
display: none;
font-size: 13px;
}
&[aria-invalid='true'] {
.error {
display: block;
}
}
}
</style>

While ideally, the switch field should not be validatable, you can still use the required attribute if you choose to use an input[type="checkbox"] as a base element. We make no assumptions about how you want to use the switch field.

NameTypeDescription
requiredbooleanWhether the number field is required.
<script setup lang="ts">
import Switch from './Switch.vue';
</script>
<template>
<Switch label="HTML Validation" required />
</template>

useSwitch also supports Standard Schema validation through the schema prop. This includes multiple providers like Zod, Valibot, Arktype, and more.

<script setup lang="ts">
import { z } from 'zod';
import Switch from './Switch.vue';
const schema = z.literal(true);
</script>
<template>
<Switch label="Standard Schema" :schema="schema" />
</template>

While it is unlikely that you need both HTML constraints and Standard Schemas to validate a switch, Formwerk supports mixed validation, which means you can use both HTML constraints and Standard Schemas to validate the switch, and they will work together.

Note that HTML constraints are validated first, so any errors from the HTML constraints will be displayed first. Then, once all HTML constraints are satisfied, the Standard Schema is validated.

<script setup lang="ts">
import { z } from 'zod';
import Switch from './Switch.vue';
const schema = z.custom((val) => {
return val !== true;
}, 'I am a paradox');
</script>
<template>
<Switch label="Mixed Validation" required :schema="schema" />
</template>

If you need to disable the native validation, you can do so by setting the disableHtmlValidation prop to true. You can also disable it globally for all fields. For more information, check out the Validation guide.

<script setup lang="ts">
import { z } from 'zod';
import Switch from './Switch.vue';
const schema = z.custom((val) => {
return val !== true;
}, 'I am a paradox');
</script>
<template>
<Switch
label="Mixed Validation"
required
:schema="schema"
disable-html-validation
/>
</template>

Use disabled to mark the switch as non-interactive. Disabled switches are not validated and will not be submitted.

If you need to prevent the user from interacting with the field while still allowing it to submit, consider using readonly instead.

<script setup lang="ts">
import Switch from './Switch.vue';
</script>
<template>
<Switch label="Disabled switch" disabled />
</template>

Readonly switches are validated and submitted, but they do not accept user input. The switch is still focusable, and the value is copyable. For more info, check the MDN.

<script setup lang="ts">
import { ref } from 'vue';
import Switch from './Switch.vue';
const value = ref(true);
</script>
<template>
<Switch label="Readonly switch" v-model="value" readonly />
</template>

You can customize the on and off values of the switch component by passing the trueValue and falseValue props. They can be anything you want.

<script setup lang="ts">
import { ref } from 'vue';
import Switch from './Switch.vue';
const value = ref('off');
</script>
<template>
<Switch
label="Custom values"
v-model="value"
:trueValue="'on'"
:falseValue="'off'"
/>
<div>Value is: {{ value }}</div>
</template>

These are the properties that can be passed to the useSwitch composable.

NameTypeDescription

These are the properties in the object returned by the useSwitch composable.

NameTypeDescription
controlId
descriptionPropsProps for the description element.
errorMessageThe error message for the field.
errorMessagePropsProps for the error message element.
errorsThe errors for the field.
field
fieldValueThe value of the field.
inputEl
inputProps
Ref<SwitchDomInputProps | SwitchDOMProps>
isBlurredWhether the field is blurred.
isDirtyWhether the field is dirty.
isDisabledWhether the field is disabled.
isPressed
isTouchedWhether the field is touched.
isValidWhether the field is valid.
isValidatedWhether the field is validated, used to determine whether to show validation errors or not.
labelPropsProps for the label element.
setBlurredSets the blurred state for the field.
setErrors
(messages: Arrayable<string>) => void
Sets the errors for the field.
setIsValidatedSets the validated state for the field.
setTouchedSets the touched state for the field.
setValueSets the value for the field.
submitErrorMessageThe error message for the field from the last submit attempt.
submitErrorsThe errors for the field from the last submit attempt.
togglePressed
validate
(mutate?: boolean) => Promise<ValidationResult<unknown>>
Validates the field.