
3D Extruded Buildings & Urban Footprints
Real-world 3D building extrusions with dynamic sunlight shadows, terrain DEM elevations, and pitch/bearing camera controls.
radio-groupuiSingle-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 ->$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.jsonnpx shadcn-vue@latest add @uipkge/radio-groupInstalls to:app/components/ui/radio-group/<script setup lang="ts">
import type { RadioGroupRootProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { computed, getCurrentInstance, provide, reactive, toRef } 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]
}>()
// reactive + toRef so children see prop updates (plain object would freeze first render).
provide(
'radioGroup',
reactive({
disabled: toRef(props, 'disabled'),
size: toRef(props, 'size'),
optionType: toRef(props, 'optionType'),
buttonVariant: toRef(props, 'buttonVariant'),
orientation: toRef(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>
<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(
() => Boolean(props.loading) || Boolean(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-success data-[state=checked]:text-success',
warning: 'data-[state=checked]:border-warning data-[state=checked]:text-warning',
error: 'data-[state=checked]:border-destructive data-[state=checked]:text-destructive',
info: 'data-[state=checked]:border-info data-[state=checked]:text-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 flex-col" :class="[densityClasses[density]]">
<div class="flex items-center">
<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>
<RadioGroupItem
v-bind="forwardedProps"
:id="id"
data-uipkge
data-slot="radio-group-item"
:value="value"
:disabled="effectiveDisabled"
:aria-busy="loading || undefined"
:aria-invalid="hasError || undefined"
: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',
loading && 'opacity-50',
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 ml-6 text-xs">
{{ hint }}
</p>
<div v-if="hasError" class="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>
<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>
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