UIPackage

Radio Group

Vue form
Edit on GitHub

Single-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

$ npx 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