const items = [
{ id: "apple", label: "Apples" },
{ id: "orange", label: "Oranges" },
{ id: "mango", label: "Mangoes" },
{ id: "grape", label: "Grapes" },
];
<RadioGroupRoot defaultValue="apple">
<RadioGroupLabel>Airplane Mode</RadioGroupLabel>
<RadioGroupItem v-for="opt in items" :key="opt.id" :value="opt.id">
<RadioGroupItemText>{{ opt.label }}</RadioGroupItemText>
<RadioGroupItemControl />
</RadioGroupItem>
</RadioGroupRoot>
npx @left4code/cosmic-ui-cli@latest add radio-group
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 selected dot just toggles opacity, no exit animation. The hidden native input renders automatically insideRadioGroupItem. Vue SFCs can't export multiple components from one file, so Radio Group is a folder instead of a single file — copy all of it into your project.
import type { InjectionKey, ComputedRef } from "vue";
import type * as radioGroup from "@zag-js/radio-group";
type RadioGroupApi = ComputedRef<ReturnType<typeof radioGroup.connect>>;
const RadioGroupApiKey: InjectionKey<RadioGroupApi> = Symbol("radio-group-api");
const RadioGroupItemValueKey: InjectionKey<string> = Symbol("radio-group-item-value");
export { RadioGroupApiKey, RadioGroupItemValueKey };
export type { RadioGroupApi };
<script setup lang="ts">
import { computed, provide, useAttrs, useId } from "vue";
import { useMachine, normalizeProps } from "@zag-js/vue";
import * as radioGroup from "@zag-js/radio-group";
import { twMerge } from "tailwind-merge";
import { RadioGroupApiKey } from "./context";
defineOptions({ inheritAttrs: false });
const rawAttrs = useAttrs() as Partial<radioGroup.Props> & { class?: string };
const id = useId();
const machineProps = computed(() => {
const { class: _class, ...rest } = rawAttrs;
return { id, ...rest };
});
const service = useMachine(radioGroup.machine, machineProps);
const api = computed(() => radioGroup.connect(service, normalizeProps));
provide(RadioGroupApiKey, api);
</script>
<template>
<div v-bind="api.getRootProps()" :class="twMerge(['flex flex-col gap-3', rawAttrs.class])">
<slot />
</div>
</template>
<script setup lang="ts">
import { inject } from "vue";
import { twMerge } from "tailwind-merge";
import { RadioGroupApiKey } from "./context";
const { class: className } = defineProps<{ class?: string }>();
const injectedApi = inject(RadioGroupApiKey);
if (!injectedApi) throw new Error("RadioGroup parts must be used within <RadioGroupRoot>");
const api = injectedApi;
</script>
<template>
<div v-bind="api.getLabelProps()" :class="twMerge(['font-bold', className])">
<slot />
</div>
</template>
<script setup lang="ts">
import { inject, provide } from "vue";
import { twMerge } from "tailwind-merge";
import { RadioGroupApiKey, RadioGroupItemValueKey } from "./context";
const { class: className, value } = defineProps<{ class?: string; value: string }>();
const injectedApi = inject(RadioGroupApiKey);
if (!injectedApi) throw new Error("RadioGroup parts must be used within <RadioGroupRoot>");
const api = injectedApi;
provide(RadioGroupItemValueKey, value);
</script>
<template>
<label
v-bind="api.getItemProps({ value })"
:class="twMerge(['flex gap-3.5 items-center cursor-pointer', className])"
>
<slot />
<input v-bind="api.getItemHiddenInputProps({ value })" />
</label>
</template>
<script setup lang="ts">
import { inject } from "vue";
import { twMerge } from "tailwind-merge";
import { RadioGroupApiKey, RadioGroupItemValueKey } from "./context";
const { class: className } = defineProps<{ class?: string }>();
const injectedApi = inject(RadioGroupApiKey);
if (!injectedApi) throw new Error("RadioGroup parts must be used within <RadioGroupRoot>");
const api = injectedApi;
const value = inject(RadioGroupItemValueKey);
if (value === undefined) throw new Error("RadioGroupItemText must be used within <RadioGroupItem>");
</script>
<template>
<span v-bind="api.getItemTextProps({ value })" :class="twMerge(['order-2', className])">
<slot />
</span>
</template>
<script setup lang="ts">
import { inject } from "vue";
import { twMerge } from "tailwind-merge";
import Frame, { parsePaths } from "@/components/ui/frame.vue";
import { RadioGroupApiKey, RadioGroupItemValueKey } from "./context";
const { class: className } = defineProps<{ class?: string }>();
const injectedApi = inject(RadioGroupApiKey);
if (!injectedApi) throw new Error("RadioGroup parts must be used within <RadioGroupRoot>");
const api = injectedApi;
const value = inject(RadioGroupItemValueKey);
if (value === undefined) throw new Error("RadioGroupItemControl must be used within <RadioGroupItem>");
const outerPaths = parsePaths(
'[{"show":true,"style":{"strokeWidth":"1","stroke":"var(--color-frame-1-stroke)","fill":"var(--color-frame-1-fill)"},"path":[["M","50% - 28.125%","0"],["L","50% + 28.125%","0"],["L","100% + 0","50% - 28.125%"],["L","100% + 0","50% + 28.125%"],["L","50% + 28.125%","100% - 0"],["L","50% - 28.125%","100% + 0"],["L","0","50% + 28.125%"],["L","0","50% - 28.125%"],["L","50% - 28.125%","0"]]}]'
);
const innerPaths = parsePaths(
'[{"show":true,"style":{"strokeWidth":"1","stroke":"var(--color-frame-1-stroke)","fill":"var(--color-frame-1-fill)"},"path":[["M","50% - 28.125%","0"],["L","50% + 28.125%","0"],["L","100% + 0","50% - 28.125%"],["L","100% + 0","50% + 28.125%"],["L","50% + 28.125%","100% - 0"],["L","50% - 28.125%","100% + 0"],["L","0","50% + 28.125%"],["L","0","50% - 28.125%"],["L","50% - 28.125%","0"]]}]'
);
</script>
<template>
<div
v-bind="api.getItemControlProps({ value })"
:class="
twMerge([
'group relative size-5 flex items-center justify-center data-[state=checked]:drop-shadow-[0_0px_20px_var(--color-primary)]',
'[--color-frame-1-stroke:var(--color-primary)]/70',
'[--color-frame-1-fill:var(--color-primary)]/10',
className,
])
"
>
<Frame :paths="outerPaths" />
<div
class="group-data-[state=checked]:opacity-100 opacity-0 relative size-3 transition-all duration-100 [--color-frame-1-stroke:var(--color-primary)] [--color-frame-1-fill:var(--color-primary)]/30"
>
<Frame :paths="innerPaths" />
</div>
</div>
</template>
export { default as RadioGroupRoot } from "./RadioGroupRoot.vue";
export { default as RadioGroupLabel } from "./RadioGroupLabel.vue";
export { default as RadioGroupItem } from "./RadioGroupItem.vue";
export { default as RadioGroupItemText } from "./RadioGroupItemText.vue";
export { default as RadioGroupItemControl } from "./RadioGroupItemControl.vue";
Update the import paths to match your project setup.
import {
RadioGroupRoot,
RadioGroupLabel,
RadioGroupItem,
RadioGroupItemText,
RadioGroupItemControl,
} from "@/components/ui/radio-group";
const items = [
{ id: "apple", label: "Apples" },
{ id: "orange", label: "Oranges" },
{ id: "mango", label: "Mangoes" },
{ id: "grape", label: "Grapes" },
];
<RadioGroupRoot defaultValue="apple">
<RadioGroupLabel>Airplane Mode</RadioGroupLabel>
<RadioGroupItem v-for="opt in items" :key="opt.id" :value="opt.id">
<RadioGroupItemText>{{ opt.label }}</RadioGroupItemText>
<RadioGroupItemControl />
</RadioGroupItem>
</RadioGroupRoot>