<SwitchRoot>
<SwitchControl />
<SwitchLabel>Airplane Mode</SwitchLabel>
</SwitchRoot>
npx @left4code/cosmic-ui-cli@latest add switch
This writes the component and everything it imports, then installs the packages it needs. Run npx @left4code/cosmic-ui-cli@latest initfirst if you haven't already (it detects Vue automatically).
Install the following dependencies:
This component is built directly on the Zag.js state machine (not a wrapper). No Portal or presence composable needed, the thumb just slides via CSS transition, no exit animation. The thumb and the hidden native input both render automatically insideSwitchControl/SwitchRoot. Vue SFCs can't export multiple components from one file, so Switch is a folder instead of a single file — copy all of it into your project.
import type { InjectionKey, ComputedRef } from "vue";
import type * as switchMachine from "@zag-js/switch";
type SwitchApi = ComputedRef<ReturnType<typeof switchMachine.connect>>;
const SwitchApiKey: InjectionKey<SwitchApi> = Symbol("switch-api");
export { SwitchApiKey };
export type { SwitchApi };
<script setup lang="ts">
import { computed, provide, useAttrs, useId } from "vue";
import { useMachine, normalizeProps } from "@zag-js/vue";
import * as switchMachine from "@zag-js/switch";
import { twMerge } from "tailwind-merge";
import { SwitchApiKey } from "./context";
defineOptions({ inheritAttrs: false });
const rawAttrs = useAttrs() as Partial<switchMachine.Props> & { class?: string };
const id = useId();
const machineProps = computed(() => {
const { class: _class, ...rest } = rawAttrs;
return { id, ...rest };
});
const service = useMachine(switchMachine.machine, machineProps);
const api = computed(() => switchMachine.connect(service, normalizeProps));
provide(SwitchApiKey, api);
</script>
<template>
<label v-bind="api.getRootProps()" :class="twMerge(['flex items-center gap-4', rawAttrs.class])">
<slot />
<input v-bind="api.getHiddenInputProps()" />
</label>
</template>
<script setup lang="ts">
import { inject } from "vue";
import { twMerge } from "tailwind-merge";
import Frame, { parsePaths } from "@/components/ui/frame.vue";
import { SwitchApiKey } from "./context";
const { class: className } = defineProps<{ class?: string }>();
const injectedApi = inject(SwitchApiKey);
if (!injectedApi) throw new Error("Switch parts must be used within <SwitchRoot>");
const api = injectedApi;
const trackPaths = parsePaths(
'[{"show":true,"style":{"strokeWidth":"1","stroke":"var(--color-frame-1-stroke)","fill":"var(--color-frame-1-fill)"},"path":[["M","11","0"],["L","100% + 0","0"],["L","100% + 0","100% + 0"],["L","0","100% + 0"],["L","0","0% + 12"],["L","11","0"]]}]'
);
const thumbPaths = parsePaths(
'[{"show":true,"style":{"strokeWidth":"1","stroke":"var(--color-frame-1-stroke)","fill":"var(--color-frame-1-fill)"},"path":[["M","7","0"],["L","100% + 0","0"],["L","100% + 0","100% + 0"],["L","0","100% + 0"],["L","0","0% + 7"],["L","7","0"]]}]'
);
</script>
<template>
<div
v-bind="api.getControlProps()"
:class="
twMerge([
'group relative w-14 h-6 flex items-center p-1 cursor-pointer',
'[--color-frame-1-stroke:var(--color-primary)]/70',
'[--color-frame-1-fill:var(--color-primary)]/10',
'data-[state=checked]:[--color-frame-1-stroke:var(--color-primary)]',
'data-[state=checked]:[--color-frame-1-fill:var(--color-primary)]/20',
className,
])
"
>
<div class="absolute inset-0 z-[-1]">
<Frame :paths="trackPaths" />
</div>
<div
v-bind="api.getThumbProps()"
class="relative w-1/2 h-3.5 z-[-1] -mb-px transition-all ms-0.5 [--color-frame-1-stroke:var(--color-primary)]/80 [--color-frame-1-fill:var(--color-primary)]/20 group-data-[state=checked]:[--color-frame-1-stroke:var(--color-primary)] group-data-[state=checked]:[--color-frame-1-fill:var(--color-primary)]/30 group-data-[state=checked]:ms-[47%] group-data-[state=checked]:drop-shadow-[0_0px_20px_var(--color-primary)]"
>
<Frame :paths="thumbPaths" />
</div>
</div>
</template>
<script setup lang="ts">
import { inject } from "vue";
import { SwitchApiKey } from "./context";
defineProps<{ class?: string }>();
const injectedApi = inject(SwitchApiKey);
if (!injectedApi) throw new Error("Switch parts must be used within <SwitchRoot>");
const api = injectedApi;
</script>
<template>
<span v-bind="api.getLabelProps()" :class="$props.class">
<slot />
</span>
</template>
export { default as SwitchRoot } from "./SwitchRoot.vue";
export { default as SwitchControl } from "./SwitchControl.vue";
export { default as SwitchLabel } from "./SwitchLabel.vue";
Update the import paths to match your project setup.
import { SwitchRoot, SwitchControl, SwitchLabel } from "@/components/ui/switch";
<SwitchRoot>
<SwitchControl />
<SwitchLabel>Airplane Mode</SwitchLabel>
</SwitchRoot>