Radio Group
Vue formSingle-selection group of radio inputs. Vertical or horizontal layout, optional descriptions per item, and full keyboard navigation. Pair with Form for validation messages.
Also available for React ->Installation
$ pnpm dlx shadcn-vue@latest add https://uipkge.dev/r/vue/radio-group.json$ npx shadcn-vue@latest add https://uipkge.dev/r/vue/radio-group.json$ yarn dlx shadcn-vue@latest add https://uipkge.dev/r/vue/radio-group.json$ bunx shadcn-vue@latest add https://uipkge.dev/r/vue/radio-group.json
Or with the named registry:
npx shadcn-vue@latest add @uipkge/radio-group
Examples
Dependencies
Files (4)
-
app/components/ui/radio-group/RadioGroup.vue 6.8 kB
<script setup lang="ts"> import type { RadioGroupRootProps } from 'reka-ui' import type { HTMLAttributes } from 'vue' import { computed, getCurrentInstance, provide } from 'vue' import { reactiveOmit } from '@vueuse/core' import { RadioGroupRoot, useForwardPropsEmits } from 'reka-ui' import { cn } from '@/lib/utils' export type RadioOption = string | { label: string; value: string; disabled?: boolean } // CLAUDE.md mandate: avoid `interface extends ReakUiX` (SFC compiler bails // in Vue 3.5+ because reka-ui has no exports.types). The intersection form // `defineProps<External & Extra>()` delegates type extraction to TS. export type RadioGroupProps = Omit<RadioGroupRootProps, 'defaultValue' | 'modelValue'> & { class?: HTMLAttributes['class'] /** The controlled value of the radio items to check. Can be binded with v-model. */ modelValue?: any /** The value of the radio items that should be checked when initially rendered. */ defaultValue?: any /** When `true`, prevents the user from interacting with the radio group */ disabled?: boolean /** The orientation of the radio items */ orientation?: 'horizontal' | 'vertical' /** When `true`, keyboard navigation will loop from last item to first, and vice versa */ loop?: boolean /** Label for the radio group */ label?: string /** Hint text for the radio group */ hint?: string /** Error messages to display */ errorMessages?: string | string[] /** Whether to show error state */ error?: boolean /** Density of the radio items */ density?: 'compact' | 'default' | 'comfortable' /** Whether the radio group appears flat */ flat?: boolean /** Whether to show a border around the group */ bordered?: boolean /** The reading direction */ dir?: 'ltr' | 'rtl' /** Array of options to render automatically */ options?: RadioOption[] /** Size of button-style radios */ size?: 'small' | 'middle' | 'large' /** Type of options to render */ optionType?: 'default' | 'button' /** Visual variant for button-style radios */ buttonVariant?: 'outline' | 'solid' } const props = withDefaults(defineProps<RadioGroupProps>(), { orientation: 'vertical', density: 'default', flat: false, bordered: false, loop: true, disabled: false, size: 'middle', optionType: 'default', buttonVariant: 'outline', }) const emits = defineEmits<{ 'update:modelValue': [value: any] }>() provide('radioGroup', { disabled: props.disabled, size: props.size, optionType: props.optionType, buttonVariant: props.buttonVariant, orientation: props.orientation, }) const delegatedProps = reactiveOmit( props, 'class', 'label', 'hint', 'errorMessages', 'error', 'density', 'flat', 'bordered', 'defaultValue', 'modelValue', 'options', 'size', 'optionType', 'buttonVariant', ) const forwarded = useForwardPropsEmits(delegatedProps, emits) const instance = getCurrentInstance() const isControlled = computed(() => Boolean(instance?.vnode?.props?.['onUpdate:modelValue'])) const userPassedModelValue = computed(() => { const raw = instance?.vnode?.props return Boolean(raw && ('modelValue' in raw || 'model-value' in raw)) }) const radioStateBindings = computed(() => { if (isControlled.value) return { 'model-value': props.modelValue } // Uncontrolled — seed default-value from explicit modelValue or defaultValue prop. if (props.defaultValue !== undefined) return { 'default-value': props.defaultValue } if (userPassedModelValue.value) return { 'default-value': props.modelValue } return {} }) // Density classes const densityClasses = { compact: 'gap-1', default: 'gap-3', comfortable: 'gap-4', } // Build error state const hasError = computed(() => { if (props.error) return true if ( props.errorMessages && (typeof props.errorMessages === 'string' ? props.errorMessages : props.errorMessages.length > 0) ) return true return false }) function normalizeOption(option: RadioOption): { label: string; value: string; disabled?: boolean } { if (typeof option === 'string') { return { label: option, value: option } } return option } </script> <template> <!-- `props.class` is forwarded ONLY to RadioGroupRoot below (per shadcn convention). Applying it on the outer wrapper as well caused grid-* utilities to fight the wrapper's `flex flex-col`, so consumers had to fall back to column-count hacks. Forwarding to one element keeps layout intent unambiguous. --> <div class="flex flex-col gap-2"> <label v-if="label" class="text-sm font-medium"> {{ label }} </label> <p v-if="hint && !hasError" class="text-muted-foreground text-xs"> {{ hint }} </p> <RadioGroupRoot v-slot="slotProps" v-bind="{ 'data-slot': 'radio-group', ...forwarded, ...radioStateBindings }" :class=" cn( 'grid gap-3', orientation === 'horizontal' && 'flex flex-row items-center gap-4', optionType === 'button' && orientation === 'horizontal' && 'flex flex-row items-stretch gap-0', optionType === 'button' && orientation === 'vertical' && 'flex flex-col items-stretch gap-0', optionType !== 'button' && densityClasses[density], bordered && 'rounded-lg border p-4', props.class, ) " @update:model-value="(val: any) => emits('update:modelValue', val)" > <template v-if="options && options.length > 0"> <template v-if="optionType === 'button'"> <RadioButton v-for="option in options" :key="normalizeOption(option).value" :value="normalizeOption(option).value" :disabled="normalizeOption(option).disabled" :label="normalizeOption(option).label" /> </template> <template v-else> <div v-for="option in options" :key="normalizeOption(option).value" class="flex items-center gap-2"> <RadioGroupItem :id="normalizeOption(option).value" :value="normalizeOption(option).value" :disabled="normalizeOption(option).disabled" /> <label :for="normalizeOption(option).value" class="cursor-pointer text-sm font-medium select-none" :class="normalizeOption(option).disabled && 'cursor-not-allowed opacity-50'" > {{ normalizeOption(option).label }} </label> </div> </template> </template> <slot v-bind="slotProps" /> </RadioGroupRoot> <div v-if="hasError" class="flex flex-col gap-0.5"> <p v-if="typeof errorMessages === 'string'" class="text-destructive text-xs"> {{ errorMessages }} </p> <template v-else> <p v-for="(msg, i) in errorMessages" :key="i" class="text-destructive text-xs"> {{ msg }} </p> </template> </div> </div> </template> -
app/components/ui/radio-group/RadioGroupItem.vue 5.8 kB
<script setup lang="ts"> import type { RadioGroupItemProps } from 'reka-ui' import type { HTMLAttributes } from 'vue' import { computed, inject } from 'vue' import { reactiveOmit } from '@vueuse/core' import { Circle } from 'lucide-vue-next' import { RadioGroupIndicator, RadioGroupItem, useForwardProps } from 'reka-ui' import { cn } from '@/lib/utils' // CLAUDE.md mandate: avoid `interface extends ReakUiX` (SFC compiler bails // in Vue 3.5+ because reka-ui has no exports.types). Intersection form below. export type RadioGroupItemPropsExtended = Omit<RadioGroupItemProps, 'defaultChecked'> & { class?: HTMLAttributes['class'] /** Size of the radio item */ size?: 'sm' | 'md' | 'lg' /** Custom color for the checked state */ color?: 'primary' | 'secondary' | 'success' | 'warning' | 'error' | 'info' | string /** Label text displayed next to the radio item */ label?: string /** Hint text shown below the radio item */ hint?: string /** Error message to display */ errorMessages?: string | string[] /** Whether to show error state */ error?: boolean /** Density of the radio item */ density?: 'compact' | 'default' | 'comfortable' /** Label position - before or after the radio */ labelPosition?: 'before' | 'after' /** Loading state */ loading?: boolean /** Hide the indicator icon */ hideIcon?: boolean } const props = withDefaults(defineProps<RadioGroupItemPropsExtended>(), { size: 'md', density: 'default', color: 'primary', labelPosition: 'after', hideIcon: false, }) const delegatedProps = reactiveOmit( props, 'class', 'size', 'color', 'label', 'hint', 'errorMessages', 'error', 'density', 'labelPosition', 'loading', 'hideIcon', ) const forwardedProps = useForwardProps(delegatedProps) const groupContext = inject<{ disabled?: boolean size?: 'small' | 'middle' | 'large' optionType?: 'default' | 'button' buttonVariant?: 'outline' | 'solid' orientation?: 'horizontal' | 'vertical' }>('radioGroup', {}) const effectiveDisabled = computed(() => props.disabled ?? groupContext.disabled ?? false) // Size classes const sizeClasses = { sm: 'size-3.5', md: 'size-4', lg: 'size-5', } const indicatorSizes = { sm: 'size-1.5', md: 'size-2', lg: 'size-2.5', } // Color classes const colorClasses: Record<string, string> = { primary: 'data-[state=checked]:border-primary', secondary: 'data-[state=checked]:border-secondary', success: 'data-[state=checked]:border-[var(--success)] data-[state=checked]:text-[var(--success)]', warning: 'data-[state=checked]:border-[var(--warning)] data-[state=checked]:text-[var(--warning)]', error: 'data-[state=checked]:border-destructive data-[state=checked]:text-destructive', info: 'data-[state=checked]:border-[var(--info)] data-[state=checked]:text-[var(--info)]', } // Density classes const densityClasses = { compact: 'gap-1', default: 'gap-2', comfortable: 'gap-3', } // Build error state const hasError = computed(() => { if (props.error) return true if ( props.errorMessages && (typeof props.errorMessages === 'string' ? props.errorMessages : props.errorMessages.length > 0) ) return true return false }) </script> <template> <div class="flex items-start" :class="[densityClasses[density]]"> <label v-if="label && labelPosition === 'before'" :for="id" class="mr-2 cursor-pointer text-sm leading-none font-medium select-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70" :class="[hasError ? 'text-destructive' : '', effectiveDisabled && 'cursor-not-allowed opacity-50']" > {{ label }} </label> <div class="flex items-center"> <RadioGroupItem v-bind="forwardedProps" :id="id" data-uipkge data-slot="radio-group-item" :value="value" :disabled="effectiveDisabled" :class=" cn( 'border-input text-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 aspect-square shrink-0 rounded-full border shadow-sm transition-colors duration-200 outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50', sizeClasses[size], colorClasses[color] || colorClasses.primary, hasError && 'border-destructive', props.class, ) " > <RadioGroupIndicator data-uipkge data-slot="radio-group-indicator" class="relative flex items-center justify-center" > <slot> <Circle v-if="!hideIcon" :class=" cn( 'absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 fill-current text-current', indicatorSizes[size], ) " /> </slot> </RadioGroupIndicator> </RadioGroupItem> <label v-if="label && labelPosition === 'after'" :for="id" class="ml-2 cursor-pointer text-sm leading-none font-medium select-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70" :class="[hasError ? 'text-destructive' : '', effectiveDisabled && 'cursor-not-allowed opacity-50']" > {{ label }} </label> </div> <p v-if="hint && !hasError" class="text-muted-foreground mt-1 ml-6 text-xs"> {{ hint }} </p> <div v-if="hasError" class="mt-1 ml-6 flex flex-col gap-0.5"> <p v-if="typeof errorMessages === 'string'" class="text-destructive text-xs"> {{ errorMessages }} </p> <template v-else> <p v-for="(msg, i) in errorMessages" :key="i" class="text-destructive text-xs"> {{ msg }} </p> </template> </div> </div> </template> -
app/components/ui/radio-group/RadioButton.vue 3 kB
<script setup lang="ts"> import type { RadioGroupItemProps } from 'reka-ui' import type { HTMLAttributes } from 'vue' import { computed, inject } from 'vue' import { reactiveOmit } from '@vueuse/core' import { RadioGroupItem, useForwardProps } from 'reka-ui' import { cn } from '@/lib/utils' // CLAUDE.md mandate: avoid `interface extends ReakUiX` (SFC compiler bails // in Vue 3.5+ because reka-ui has no exports.types). Intersection form below. export type RadioButtonProps = Omit<RadioGroupItemProps, 'defaultChecked'> & { class?: HTMLAttributes['class'] /** Size of the button radio */ size?: 'small' | 'middle' | 'large' /** Visual variant */ variant?: 'outline' | 'solid' /** Label text */ label?: string } const props = withDefaults(defineProps<RadioButtonProps>(), { size: undefined, variant: undefined, }) const context = inject<{ disabled?: boolean size?: 'small' | 'middle' | 'large' optionType?: 'default' | 'button' buttonVariant?: 'outline' | 'solid' orientation?: 'horizontal' | 'vertical' }>('radioGroup', {}) const effectiveSize = computed(() => props.size ?? context.size ?? 'middle') const effectiveVariant = computed(() => props.variant ?? context.buttonVariant ?? 'outline') const effectiveDisabled = computed(() => props.disabled ?? context.disabled ?? false) const delegatedProps = reactiveOmit(props, 'class', 'size', 'variant', 'label') const forwardedProps = useForwardProps(delegatedProps) const sizeClasses = { small: 'h-7 px-2.5 text-xs', middle: 'h-8 px-4 text-sm', large: 'h-10 px-4.5 text-base', } const variantClasses = { outline: cn( 'border border-input bg-transparent text-foreground hover:text-foreground hover:bg-muted/50', 'data-[state=checked]:border-primary data-[state=checked]:text-primary', 'disabled:hover:bg-transparent', ), solid: cn( 'border border-input bg-transparent text-foreground hover:text-foreground hover:bg-muted/50', 'data-[state=checked]:border-primary data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground', 'disabled:hover:bg-transparent', ), } const groupClasses = computed(() => { if (context.orientation === 'vertical') { return 'rounded-md w-full justify-start' } return cn('rounded-none first:rounded-l-md last:rounded-r-md', 'border-l-0 first:border-l', '-ml-px first:ml-0') }) </script> <template> <RadioGroupItem v-bind="forwardedProps" data-uipkge data-slot="radio-button" :value="value" :disabled="effectiveDisabled" :class=" cn( 'inline-flex items-center justify-center gap-2 font-medium whitespace-nowrap transition-colors duration-200', 'focus-visible:border-ring focus-visible:ring-ring/50 outline-none focus-visible:ring-[3px]', 'disabled:cursor-not-allowed disabled:opacity-50', sizeClasses[effectiveSize], variantClasses[effectiveVariant], groupClasses, props.class, ) " > <slot> {{ label ?? value }} </slot> </RadioGroupItem> </template> -
app/components/ui/radio-group/index.ts 0.2 kB
export { default as RadioGroup } from './RadioGroup.vue' export { default as RadioGroupItem } from './RadioGroupItem.vue' export { default as RadioButton } from './RadioButton.vue'
Raw manifest: https://uipkge.dev/r/vue/radio-group.json