Getting Started
IntroductionHow to Use
Components
FrameMenuAlertAccordionDialogTabsToastNewButtonInputSwitchTextareaRadio GroupCheckboxChartComboboxSelectNewAlert DialogSoonAvatarSoonBreadcrumbSoonButton GroupSoonCalendarSoonDatepickerSoonDaterangepickerSoonSheetSoonNumber InputSoonPaginationSoonTableSoon

Radio Group

A set of checkable buttons—known as radio buttons—where no more than one of the buttons can be checked at a time.
Airplane Mode
Installation
Expand

npx @left4code/cosmic-ui-cli@latest add radio-group
 
Expand

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:

pnpm
npm
yarn
bun
yarn add @zag-js/radio-group @zag-js/vue

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.

components/ui/radio-group/context.ts
Expand

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 };
 
Expand
components/ui/radio-group/RadioGroupRoot.vue
Expand

<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>
 
Expand
components/ui/radio-group/RadioGroupLabel.vue
Expand

<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>
 
Expand
components/ui/radio-group/RadioGroupItem.vue
Expand

<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>
 
Expand
components/ui/radio-group/RadioGroupItemText.vue
Expand

<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>
 
Expand
components/ui/radio-group/RadioGroupItemControl.vue
Expand

<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>
 
Expand
components/ui/radio-group/index.ts
Expand

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";
 
Expand

Update the import paths to match your project setup.

Usage
Expand

import {
  RadioGroupRoot,
  RadioGroupLabel,
  RadioGroupItem,
  RadioGroupItemText,
  RadioGroupItemControl,
} from "@/components/ui/radio-group";
 
Expand
Expand

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>
 
Expand
Powered by synthetic caffeine · Deployed by Left4code · Signal traceable on GitHub.