Switch
An on-off switch for a setting that applies as soon as it is flipped.
Switch is a two-state control for a setting the user turns on and off. Useful
wherever the change should apply the moment it is made; for a choice that only
counts once a form is submitted, use Checkbox.
<template>
<div class="flex flex-col gap-3">
<Field.Root
v-for="setting in settings"
:key="setting.name"
class="flex items-center gap-3 data-[disabled=true]:cursor-not-allowed"
:options="{ disabled: setting.disabled, name: setting.name }"
>
<Switch.Root v-model="setting.enabled" :class="track">
<Switch.Thumb :class="thumb" />
</Switch.Root>
<Label :class="label">{{ setting.label }}</Label>
</Field.Root>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { Field, Label, Switch } from '@maas/mirror/vue'
const track = [
'group/switch flex h-6 w-[2.625rem] items-center p-[0.0625rem]',
'rounded-component-round border-2 border-[transparent] bg-primary-subtle',
'transition-all duration-100 ease-[ease]',
'data-[disabled=true]:pointer-events-none',
'outline-4 outline-transparent focus-visible:focus-ring',
'not-data-[disabled=true]:active:bg-primary-subtle-active',
'data-[state=checked]:bg-accent-solid',
'not-data-[disabled=true]:data-[state=checked]:active:bg-accent-solid-active',
'data-[disabled=true]:bg-disabled-muted',
].join(' ')
const thumb = [
'rounded-component-round bg-primary-light block size-[1.125rem]',
'shadow-[0_0.0625rem_0.125rem_0_oklch(0%_0_none/0.24)]',
'transition-all duration-100 ease-[ease]',
'data-[state=checked]:translate-x-[1.125rem]',
'not-data-[disabled=true]:group-active/switch:w-[1.375rem]',
'not-data-[disabled=true]:group-active/switch:data-[state=checked]:translate-x-[0.875rem]',
'data-[disabled=true]:bg-disabled-light',
].join(' ')
const label = [
'type-component-xs text-surface [--mirror-label-cursor:pointer]',
'data-[disabled=true]:text-disabled-solid',
].join(' ')
const settings = ref([
{
name: 'notifications',
label: 'Email notifications',
enabled: true,
disabled: false,
},
{ name: 'digest', label: 'Weekly digest', enabled: false, disabled: false },
{ name: 'beta', label: 'Beta features', enabled: false, disabled: true },
])
</script>
Usage guidelines
- Make sure the switch has an accessible name, either through a
Labelinside aFieldor anaria-labelon the root. readOnlynever reaches the hidden input, so a read-only switch still submits its value. Usedisabledto leave it out of the submission.
Anatomy
Assemble the root and its thumb.
| Name | Required | Description |
|---|---|---|
| Switch.Root | true | Renders a button with role="switch", plus a visually hidden checkbox input. |
| Switch.Thumb | Renders a span. The moving part, mounted in both states. |
<script setup lang="ts">
import { Field, Label, Switch } from '@maas/mirror/vue'
</script>
<template>
<Field.Root :options="{ name: 'notifications' }">
<Switch.Root v-model="enabled">
<Switch.Thumb />
</Switch.Root>
<Label>Email notifications</Label>
</Field.Root>
</template>
Examples
Controlled and uncontrolled
Leave modelValue out and the switch keeps its own state, seeded by
defaultValue. Bind v-model and you own it instead, which is
decided once at mount.
<template>
<Switch.Root default-value>
<Switch.Thumb />
</Switch.Root>
<Switch.Root v-model="enabled">
<Switch.Thumb />
</Switch.Root>
</template>
<script setup lang="ts">
const enabled = ref(false)
</script>
Inside a form
The hidden input takes name, value, required and disabled, so
the form sees it, and a surrounding Field supplies each of them.
<template>
<form>
<Field.Root :options="{ name: 'notifications' }">
<Switch.Root>
<Switch.Thumb />
</Switch.Root>
<Label>Email notifications</Label>
</Field.Root>
</form>
</template>
A switch that applies immediately usually has no form around it. If all you want
is the value in FormData, set name on the root and skip the Field.
<template>
<Switch.Root name="notifications" value="on">
<Switch.Thumb />
</Switch.Root>
</template>
An off switch submits nothing, which is the native checkbox behaviour. Where
the server needs to hear about both states, set uncheckedValue and a second
hidden input carries it while the switch is off.
<template>
<Switch.Root name="notifications" unchecked-value="off" value="on">
<Switch.Thumb />
</Switch.Root>
</template>
A switch rendered outside the <form> it belongs to points at it by id
through form, which both hidden inputs take.
Reaching the hidden input
The element the form submits is exposed as input on the component instance,
for the rare case where you need to read validity or call
setCustomValidity() on it.
<template>
<Switch.Root ref="notifications" name="notifications" />
</template>
<script setup lang="ts">
const notifications = useTemplateRef('notifications')
function report() {
notifications.value?.input?.reportValidity()
}
</script>
Styling from state
Both parts write the same state, so the thumb positions itself from
data-state without a wrapper class.
<template>
<Switch.Root class="switch">
<Switch.Thumb class="thumb" />
</Switch.Root>
</template>
<style>
.switch {
display: flex;
width: 2.5rem;
padding: 0.125rem;
border-radius: var(--app-dimension-radius-round);
background: var(--app-color-surface-bg-high);
}
.switch[data-state='checked'] {
background: var(--app-color-primary-bg-solid);
}
.thumb {
width: 1.25rem;
height: 1.25rem;
border-radius: var(--app-dimension-radius-round);
transition: translate 120ms ease;
}
.thumb[data-state='checked'] {
translate: 1rem 0;
}
</style>
API reference
Module. A bundled options object, and useMirrorSwitch(id) as
the programmatic API. There is no indeterminate: a switch has two states by
definition.
Switch.Root
Renders a <button role="switch"> plus a visually hidden
<input type="checkbox">, as a sibling of the button rather than a child.
Props
Emits
Slot props
The field state set, plus the one below.
Switch.Root also exposes input, the hidden <input type="checkbox">
element, on its instance.
Switch.Thumb
Renders a <span>, mounted in both states, with no props beyond the primitive
ones.
Slot props
The same as the root.
Composable
useMirrorSwitch(id) reaches a switch from anywhere in the app.
Data attributes
Both parts write the field state set, plus the ones below.
CSS variables
Errors
Accessibility
The rendered element takes role="switch" with aria-checked, aria-required
follows required, and a non-native switch reports aria-disabled rather than
leaving the tab order.