Morph Toggle
A switch with three faces. The default variant is a clean track and a round
thumb. morph adds a second idea: the thumb is a rounded square while off and
turns into a circle as it travels, so the shape itself says which state you are
looking at. icon keeps the circle and cross-fades a pair of icons inside it,
rotating the outgoing one away.
A short transition only ever touches the thumb's offset and radius, so the track never reflows and the switch keeps a stable footprint in a form row.
Usage
The state is controlled with v-model. defaultChecked covers the uncontrolled
case, and disabled removes both the pointer and the keyboard path in one
attribute.
<template>
<div class="flex flex-col items-start gap-6 py-8">
<div class="flex items-center gap-3">
<MMorphToggle
v-model="email"
label="Email notifications"
/>
<span class="text-sm text-muted">Email notifications</span>
</div>
<div class="flex items-center gap-3">
<MMorphToggle
v-model="morph"
label="Weekly digest"
variant="morph"
/>
<span class="text-sm text-muted">Weekly digest (morph)</span>
</div>
<div class="flex items-center gap-3">
<MMorphToggle
v-model="dark"
label="Dark mode"
size="lg"
variant="icon"
>
<template #icon-on>
<UIcon
class="size-3.5"
name="i-lucide-moon"
/>
</template>
<template #icon-off>
<UIcon
class="size-3.5"
name="i-lucide-sun"
/>
</template>
</MMorphToggle>
<span class="text-sm text-muted">Dark mode (icon)</span>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
const email = ref(true)
const morph = ref(false)
const dark = ref(true)
</script>
The icon variant falls back to a check and a cross when no slots are passed,
so it is usable before you have icon assets wired up.
Customize
Customize
Props
| Prop | Type | Default | Description |
|---|---|---|---|
modelValue | boolean | - | Controlled state. Leave it out to let the component keep its own. |
defaultChecked | boolean | false | Initial state in uncontrolled usage. |
disabled | boolean | false | Disable the switch. |
label | string | - | Accessible name for the switch. Pass it whenever no visible label is associated with the control. |
size | 'sm' | 'md' | 'lg' | 'md' | Size of the track and thumb. |
variant | 'default' | 'morph' | 'icon' | 'default' | Visual treatment. |
class | string | - | Extra classes for the button. |
Events
| Event | Payload | Description |
|---|---|---|
update:modelValue | (value: boolean) => void | Fired with the next state on click, Space or Enter. Pair it with modelValue
for controlled usage. |
Slots
| Slot | Props | Description |
|---|---|---|
icon-on | - | Icon shown inside the thumb while checked. Only used by the icon variant. |
icon-off | - | Icon shown inside the thumb while unchecked. Only used by the icon variant. |
Accessibility
- The control is a native
<button role="switch">witharia-checked, so screen readers announce both the role and the current state. - Space and Enter work through native button activation rather than a custom key handler, which keeps the browser's default behaviour intact.
disabledsets the button'sdisabledattribute, removing it from pointer and keyboard interaction and announcing the state.- The thumb position and shape are never the only signal:
aria-checkedcarries the state for assistive technology. - Focus is a two-pixel ring offset from the track, independent of the checked colour.
- Under
prefers-reduced-motionthe thumb jumps straight to its new offset and shape, and the icon cross-fade becomes an instant swap.
Performance
- Only the thumb animates, and only on
transformandborder-radius; the track changes colour through the same CSS transition. - The transition runs on the compositor with no JavaScript per frame, so toggling does not force a synchronous reflow.
- Icons swap through a Vue transition with two keys, so no hidden pair is kept alive.
- The component holds no timers and no observers.