Skip to content

Switch

An on-off switch for a setting that applies as soon as it is flipped.

View source View as Markdown

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.

App.vue
<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 Label inside a Field or an aria-label on the root.
  • readOnly never reaches the hidden input, so a read-only switch still submits its value. Use disabled to leave it out of the submission.

Anatomy

Assemble the root and its thumb.

NameRequiredDescription
Switch.Root true Renders a button with role="switch", plus a visually hidden checkbox input.
Switch.ThumbRenders 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

PropTypeDefault
id
stringgenerated
modelValue
boolean | undefinedundefined
defaultValue
booleanfalse
options
SwitchOptions{}
name
stringinherited from Field
form
stringundefined
value
string'on'
uncheckedValue
stringundefined
disabled
booleaninherited from Field, else false
readOnly
booleaninherited from Field, else false
required
booleaninherited from Field, else false

Emits

EmitPayload
update:modelValue
boolean

Slot props

The field state set, plus the one below.

PropType
checked
boolean

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.

KeyType
checked
ComputedRef<boolean>
disabled
ComputedRef<boolean>
readOnly
ComputedRef<boolean>
setChecked
(next: boolean) => void
toggle
() => void

Data attributes

Both parts write the field state set, plus the ones below.

AttributeValue
data-disabled
true
data-readonly
true
data-required
true
data-valid
true
data-invalid
true
data-dirty
true
data-touched
true
data-filled
true
data-focused
true
data-state
checked | unchecked
data-checked
true

CSS variables

PartVariableDefault
Switch.Root
--mirror-switch-cursor
pointer
Switch.Root
--mirror-switch-disabled-cursor
not-allowed
Switch.Thumb
--mirror-switch-thumb-pointer-events
none

Errors

Code
missing_switch_context

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.

KeyAction
EnterToggles, on keydown. Unlike Checkbox, the default is not blocked.
SpaceToggles, on keyup.