RadioGroup
Radio group for selecting a single option from a list
Usage
<script setup lang="ts">
import { RadioGroup } from '@hareui/vue'
</script><script setup lang="ts">import type { RadioGroupItem } from '@hareui/vue'import { RadioGroup } from '@hareui/vue'const items: RadioGroupItem[] = [ { value: 'basic', label: 'Basic Plan', description: 'Includes 100 messages per month' }, { value: 'premium', label: 'Premium Plan', description: 'Includes 200 messages per month' }, { value: 'business', label: 'Business Plan', description: 'Unlimited messages' },]</script><template> <RadioGroup default-value="premium" name="plan" label="Plan selection" description="Choose the plan that suits you best" :items="items" /></template>Anatomy
RadioGroup renders a Label and a Description from props, then one row per items entry — each with its own content button, control, indicator, and optional Description — followed by a FieldError when invalid is true.
<template>
<RadioGroup :items="items" label="…" description="…" error-message="…" :invalid="…">
<template #label /> <!-- Label content -->
<template #description /> <!-- Description content -->
<template #item-label="{ item, index }" /> <!-- replaces item.label -->
<template #item-description="{ item, index }" /> <!-- replaces item.description -->
<template #indicator="{ item, checked }" /> <!-- custom content inside each control -->
<template #error /> <!-- FieldError content, rendered when invalid -->
</RadioGroup>
</template>ui key | data-slot | Element |
|---|---|---|
base | radio-group | root element |
item | radio | per-item row (<div>) |
content | radio-content | the clickable control + label button |
control | radio-control | circular control |
indicator | radio-indicator | inner dot / #indicator content |
| — | — | Label (iff label / #label) |
| — | — | Description (group, iff description / #description) |
| — | — | Description (per-item, iff item.description / #item-description) |
| — | — | FieldError (while invalid, iff error-message / #error / validation errors) |
Typing items
items takes the exported RadioGroupItem type. Extra fields are kept: extend the type, and the slots receive your type, so no casts are needed.
import type { RadioGroupItem } from '@hareui/vue'
interface MyItem extends RadioGroupItem {
icon: string
}
const items: MyItem[] = [/* ... */]
// <template #item-label="{ item }"> — item.icon is a stringExamples
Horizontal Orientation
<script setup lang="ts">import type { RadioGroupItem } from '@hareui/vue'import { Label, RadioGroup } from '@hareui/vue'const items: RadioGroupItem[] = [ { value: 'starter', label: 'Starter', description: 'For side projects' }, { value: 'pro', label: 'Pro', description: 'Advanced reporting' }, { value: 'teams', label: 'Teams', description: 'Up to 10 teammates' },]</script><template> <div class="flex flex-col gap-4"> <Label>Subscription plan</Label> <RadioGroup default-value="pro" name="plan-orientation" orientation="horizontal" :items="items" /> </div></template>Variants
The RadioGroup component supports two visual variants:
primary(default) - Standard styling with default background, suitable for most use casessecondary- Lower emphasis variant, suitable for use alongside surface-style backgrounds
Primary variant
Secondary variant
<script setup lang="ts">import type { RadioGroupItem } from '@hareui/vue'import { RadioGroup } from '@hareui/vue'const primaryItems: RadioGroupItem[] = [ { value: 'option1', label: 'Option 1', description: 'Standard styling with default background' }, { value: 'option2', label: 'Option 2', description: 'Another option with primary styling' },]const secondaryItems: RadioGroupItem[] = [ { value: 'option1', label: 'Option 1', description: 'Lower emphasis variant for use in surfaces' }, { value: 'option2', label: 'Option 2', description: 'Another option with secondary styling' },]</script><template> <div class="flex flex-col gap-8"> <div class="flex flex-col gap-2"> <p class="text-sm font-medium text-muted"> Primary variant </p> <RadioGroup default-value="option1" name="primary-plan" variant="primary" :items="primaryItems" /> </div> <div class="flex flex-col gap-2"> <p class="text-sm font-medium text-muted"> Secondary variant </p> <RadioGroup default-value="option1" name="secondary-plan" variant="secondary" :items="secondaryItems" /> </div> </div></template>In Surface
When used inside a Surface component, use variant="secondary" to apply the lower emphasis variant suitable for surface backgrounds.
<script setup lang="ts">import type { RadioGroupItem } from '@hareui/vue'import { RadioGroup, Surface } from '@hareui/vue'const items: RadioGroupItem[] = [ { value: 'basic', label: 'Basic Plan', description: 'Includes 100 messages per month' }, { value: 'premium', label: 'Premium Plan', description: 'Includes 200 messages per month' }, { value: 'business', label: 'Business Plan', description: 'Unlimited messages' },]</script><template> <Surface class="w-full rounded-3xl p-6"> <RadioGroup default-value="premium" name="plan-on-surface" variant="secondary" label="Plan selection" description="Choose the plan that suits you best" :items="items" /> </Surface></template>Disabled
<script setup lang="ts">import type { RadioGroupItem } from '@hareui/vue'import { RadioGroup } from '@hareui/vue'const items: RadioGroupItem[] = [ { value: 'starter', label: 'Starter', description: 'For side projects and small teams' }, { value: 'pro', label: 'Pro', description: 'Advanced reporting and analytics' }, { value: 'teams', label: 'Teams', description: 'Share access with up to 10 teammates' },]</script><template> <RadioGroup disabled default-value="pro" name="plan-disabled" label="Subscription plan" description="Plan changes are temporarily paused while we roll out updates." :items="items" /></template>Controlled
Bind the selected value with v-model.
Selected plan: pro
<script setup lang="ts">import type { RadioGroupItem } from '@hareui/vue'import { RadioGroup } from '@hareui/vue'import { ref } from 'vue'const value = ref('pro')const items: RadioGroupItem[] = [ { value: 'starter', label: 'Starter', description: 'For side projects and small teams' }, { value: 'pro', label: 'Pro', description: 'Advanced reporting and analytics' }, { value: 'teams', label: 'Teams', description: 'Share access with up to 10 teammates' },]</script><template> <div class="flex flex-col gap-4"> <RadioGroup v-model="value" name="plan-controlled" label="Subscription plan" :items="items" /> <p class="text-sm text-muted"> Selected plan: <span class="font-medium">{{ value }}</span> </p> </div></template>Uncontrolled
Combine default-value with @update:model-value when you only need to react to updates.
Last chosen plan: pro
<script setup lang="ts">import type { RadioGroupItem } from '@hareui/vue'import { RadioGroup } from '@hareui/vue'import { ref } from 'vue'const selection = ref('pro')const items: RadioGroupItem[] = [ { value: 'starter', label: 'Starter', description: 'For side projects and small teams' }, { value: 'pro', label: 'Pro', description: 'Advanced reporting and analytics' }, { value: 'teams', label: 'Teams', description: 'Share access with up to 10 teammates' },]</script><template> <div class="flex flex-col gap-4"> <RadioGroup default-value="pro" name="plan-uncontrolled" label="Subscription plan" :items="items" @update:model-value="(next) => (selection = next ?? selection)" /> <p class="text-sm text-muted"> Last chosen plan: <span class="font-medium">{{ selection }}</span> </p> </div></template>Validation
Mark the group required inside a Form: submitting without a selection is blocked and shows the error. A validate function receives the selected value, and invalid overrides both.
<script setup lang="ts">import type { RadioGroupItem } from '@hareui/vue'import { Button, Form, RadioGroup } from '@hareui/vue'import { ref } from 'vue'const message = ref<string | null>(null)const items: RadioGroupItem[] = [ { value: 'starter', label: 'Starter', description: 'For side projects and small teams' }, { value: 'pro', label: 'Pro', description: 'Advanced reporting and analytics' }, { value: 'teams', label: 'Teams', description: 'Share access with up to 10 teammates' },]function onSubmit(e: Event) { e.preventDefault() const formData = new FormData(e.currentTarget as HTMLFormElement) const value = formData.get('plan-validation') message.value = `Your chosen plan is: ${value}`}</script><template> <Form class="flex flex-col gap-4" @submit="onSubmit"> <RadioGroup required name="plan-validation" label="Subscription plan" error-message="Choose a subscription before continuing." :items="items" /> <Button class="mt-2 w-fit" type="submit"> Submit </Button> <p v-if="!!message" class="text-sm text-muted"> {{ message }} </p> </Form></template>Delivery & Payment
<script setup lang="ts">import type { RadioGroupItem } from '@hareui/vue'import type { IconifyIcon } from '@iconify/vue'import { Icon } from '@iconify/vue'import { Description, RadioGroup } from '@hareui/vue'interface DeliveryOption extends RadioGroupItem { price: string}const deliveryOptions: DeliveryOption[] = [ { description: '4-10 business days', price: '$5.00', label: 'Standard', value: 'standard' }, { description: '2-5 business days', price: '$16.00', label: 'Express', value: 'express' }, { description: '1 business day', price: '$25.00', label: 'Super Fast', value: 'super-fast' },]// `uim:master-card`, `streamline-logos:visa-logo-solid`, `ic:baseline-paypal` (Iconify), inlined so the// docs stay offline-capable with only the gravity-ui collection bundled.const mastercard: IconifyIcon = { width: 24, height: 24, body: '<path fill="currentColor" d="M15.273 18.728A6.728 6.728 0 1 1 22 11.999V12a6.735 6.735 0 0 1-6.727 6.728" opacity=".5"/><path fill="currentColor" d="M8.727 18.728A6.728 6.728 0 1 1 15.455 12a6.735 6.735 0 0 1-6.728 6.728"/>',}const visa: IconifyIcon = { width: 24, height: 24, body: '<path fill="currentColor" fill-rule="evenodd" d="M1 8.5h2.698a1 1 0 0 1 .976.783L5.5 13L7 8.5h2l-2.5 7h-2L3 9.5zm9 0h1.5l-1 7H9zm5.003 0c.8 0 1.62.124 1.954.291l-.236 1.426c-.303-.165-.99-.31-1.648-.31s-.846.415-.846.595c0 .247.333.424.756.648c.734.39 1.738.923 1.738 2.217c0 1.502-1.902 2.133-2.903 2.133c-.8 0-1.778-.124-2.112-.291l.235-1.426c.299.103 1.123.333 1.781.333c.66 0 1.147-.333 1.147-.618c0-.426-.422-.646-.915-.903c-.694-.362-1.529-.797-1.529-1.985c0-1.502 1.577-2.11 2.578-2.11m1.497 7h2l.343-1h2.29l.223 1H23l-1.5-7h-2zm4.311-2.502h-1.48l.936-2.527z" clip-rule="evenodd"/>',}const paypal: IconifyIcon = { width: 24, height: 24, body: '<path fill="currentColor" d="M9.93 12.99c.1 0 2.42.1 3.8-.24h.01c1.59-.39 3.8-1.51 4.37-5.17c0 0 1.27-4.58-5.03-4.58H7.67c-.49 0-.91.36-.99.84L4.38 18.4c-.05.3.19.58.49.58H8.3l.84-5.32c.06-.38.39-.67.79-.67"/><path fill="currentColor" d="M18.99 8.29c-.81 3.73-3.36 5.7-7.42 5.7H10.1l-1.03 6.52c-.04.26.16.49.42.49h1.9c.34 0 .64-.25.69-.59c.08-.4.52-3.32.61-3.82c.05-.34.35-.59.69-.59h.44c2.82 0 5.03-1.15 5.68-4.46c.26-1.34.12-2.44-.51-3.25"/>',}interface PaymentOption extends RadioGroupItem { icon: IconifyIcon}const paymentOptions: PaymentOption[] = [ { description: 'Exp. on 01/2026', icon: mastercard, label: '**** 8304', value: 'mastercard' }, { description: 'Exp. on 01/2026', icon: visa, label: '**** 0123', value: 'visa' }, { description: 'Pay with PayPal', icon: paypal, label: 'PayPal', value: 'paypal' },]// HeroUI wraps the radios in `<div class="grid gap-x-4 md:grid-cols-N">`; the group root takes that grid here,// with the label spanning every column.const deliveryBase = 'grid gap-x-4 md:grid-cols-3 [&>[data-slot=label]]:col-span-full'const paymentBase = 'grid gap-x-4 md:grid-cols-2 [&>[data-slot=label]]:col-span-full'const deliveryContent = [ 'group relative flex w-full flex-col gap-6 rounded-xl border border-transparent bg-surface px-5 py-4 transition-all data-[selected=true]:border-accent data-[selected=true]:bg-accent/10', 'focus-visible:border-accent focus-visible:bg-accent/10',]const paymentContent = [ 'group relative flex w-full flex-row items-start justify-start gap-4 rounded-xl border border-transparent bg-surface px-5 py-4 transition-all', 'data-[selected=true]:border-accent data-[selected=true]:bg-accent/10',]const control = 'absolute end-4 top-3 size-5'</script><template> <div class="flex w-full flex-col items-center gap-10" style="--accent: #006FEE; --accent-foreground: #fff; --accent-hover: #006FEE; --border-width: 2px; --border-width-field: 2px; --focus: #006FEE;" > <section class="flex w-full max-w-lg flex-col gap-4"> <RadioGroup default-value="express" name="delivery" variant="secondary" label="Delivery method" :items="deliveryOptions.map(({ label, value }) => ({ label, value }))" :ui="{ base: deliveryBase, content: deliveryContent, control }" > <template #item-label="{ index }"> <div class="flex flex-col gap-1"> <span>{{ deliveryOptions[index]!.label }}</span> <Description>{{ deliveryOptions[index]!.description }}</Description> </div> <span class="text-sm font-semibold">{{ deliveryOptions[index]!.price }}</span> </template> </RadioGroup> </section> <section class="flex w-full max-w-lg flex-col gap-4"> <RadioGroup default-value="visa" name="payment" variant="secondary" label="Payment method" :items="paymentOptions.map(({ label, value }) => ({ label, value }))" :ui="{ base: paymentBase, content: paymentContent, control }" > <template #item-label="{ index }"> <Icon class="size-6" :icon="paymentOptions[index]!.icon" /> <div class="flex flex-col gap-1"> <span>{{ paymentOptions[index]!.label }}</span> <Description>{{ paymentOptions[index]!.description }}</Description> </div> </template> </RadioGroup> </section> </div></template>Custom Indicator
<script setup lang="ts">import type { RadioGroupItem } from '@hareui/vue'import { RadioGroup } from '@hareui/vue'const items: RadioGroupItem[] = [ { value: 'basic', label: 'Basic Plan', description: 'Includes 100 messages per month' }, { value: 'premium', label: 'Premium Plan', description: 'Includes 200 messages per month' }, { value: 'business', label: 'Business Plan', description: 'Unlimited messages' },]</script><template> <RadioGroup default-value="premium" name="plan-custom-indicator" label="Plan selection" description="Choose the plan that suits you best" :items="items" > <template #indicator="{ checked }"> <span v-if="checked" class="text-xs leading-none text-background">✓</span> </template> </RadioGroup></template>Customization
Tailwind CSS
Use the #item-label slot and :ui overrides to restyle each row. HeroUI's render-state attributes data-[hovered|pressed|focus-visible=true] map to hover: / active: / focus-visible:.
<script setup lang="ts">import type { RadioGroupItem } from '@hareui/vue'import { Description, RadioGroup } from '@hareui/vue'const options: RadioGroupItem[] = [ { description: '$12 billed every month', label: 'Monthly', value: 'monthly' }, { description: '$120 billed once a year', label: 'Yearly', value: 'yearly' },]// HeroUI's `data-[hovered|pressed|focus-visible=true]` render-state attributes map to `hover:` / `active:` / `focus-visible:`.const contentClass = 'group flex w-full items-start gap-3 rounded-xl border border-success/10 bg-success-soft/30 px-4 py-3 transition-colors focus-visible:ring-2 focus-visible:ring-success/15 hover:bg-success-soft-hover data-[selected=true]:border-success/30 data-[selected=true]:bg-success-soft data-[selected=true]:hover:bg-success-soft-hover'const controlClass = 'mt-0.5 size-5 shrink-0 rounded-full border border-border bg-default shadow-none group-active/radio:scale-95 group-data-[selected=true]/radio:border-transparent group-data-[selected=true]/radio:bg-success group-data-[selected=true]/radio:group-active/radio:bg-success-hover'const indicatorClass = 'empty:before:rounded-full empty:before:bg-default group-data-[selected=true]/radio:empty:before:scale-50 group-data-[selected=true]/radio:empty:before:bg-success-foreground group-data-[selected=true]/radio:group-active/radio:empty:before:scale-[0.57]'</script><template> <RadioGroup default-value="yearly" name="billing" variant="secondary" label="Billing cycle" description="Choose how often you are charged." class="w-full max-w-sm gap-3 **:data-[slot=radio]:mt-0" :items="options.map(({ label, value }) => ({ label, value }))" :ui="{ content: contentClass, control: controlClass, indicator: indicatorClass }" > <template #item-label="{ index }"> <div class="flex flex-col gap-0.5"> <span class="font-medium text-foreground group-data-[selected=true]:text-success-soft-foreground"> {{ options[index]!.label }} </span> <Description class="group-data-[selected=true]:text-success-soft-foreground/80"> {{ options[index]!.description }} </Description> </div> </template> </RadioGroup></template>Global Configuration
app.use(createHareUI({
ui: { radioGroup: { slots: { control: 'size-5' } } },
}))Styling Reference
Slots
base→[data-slot="radio-group"]– root container (flex flex-col)item→[data-slot="radio"]– one row per item (flex flex-col items-start gap-1)content→[data-slot="radio-content"]– the clickable button wrapping control + labelcontrol→[data-slot="radio-control"]– circular controlindicator→[data-slot="radio-indicator"]– inner dot, empty by default; fill with#indicatorfor custom content
Note: The child components (Label, Description, FieldError) have their own themes. See their respective pages for customization options.
Interactive States
RadioGroup sets these data attributes:
- Selected (per item):
[data-selected="true"]on[data-slot="radio"]and on its[data-slot="radio-content"] - Disabled:
[data-disabled="true"]on[data-slot="radio"](groupdisabledoritem.disabled) - Invalid:
[data-invalid="true"]on[data-slot="radio"]when the groupinvalidis true - Read only:
[data-readonly="true"]andaria-readonly="true"on the root - Required:
[data-required="true"]on the root
API Reference
Props
| Prop | Type | Default | Description |
|---|---|---|---|
items | RadioGroupItem[] | [] | The radio items. |
defaultValue | string | - | The value selected initially when uncontrolled. |
label | string | - | The group label. |
description | string | - | Help text rendered below the group label. |
errorMessage | string | - | Error message rendered below the items while invalid. Defaults to the validation errors. |
invalid | boolean | undefined | Whether the group is invalid. Overrides validation when set. |
disabled | boolean | false | Whether the whole group is disabled. |
readonly | boolean | false | Whether the group is read only: items stay focusable and announced, but the selection can't change. |
required | boolean | false | Whether a selection is required before form submission. |
validate | ValidateFn<string> | - | Validates the selected value (not called while nothing is selected). Return an error message (or several) when invalid. |
validationBehavior | ValidationBehavior | 'native' | native blocks form submission and shows errors on change or submit; aria shows errors in realtime.
Defaults to the surrounding Form. |
name | string | - | The name of the group, used when submitting an HTML form. |
orientation | "horizontal" | "vertical" | "vertical" | The axis the items are laid out along. |
variant | "primary" | "secondary" | 'primary' | Visual style of the radios. |
id | string | - | Base id of the group; item controls get ${id}-${index}. Generated when omitted. |
ui | ComponentSlots<{ slots: { base: string; item: string[]; content: string[]; control: string[]; indicator: string[]; }; variants: { variant: { primary: { base: string; }; secondary: { control: string[]; indicator: string[]; }; }; orientation: { vertical: { base: string; }; horizontal: { base: string; }; }; }; defaultVariants: { variant: string; orientation: string; }; }> | - | Per-slot class overrides. |
modelValue | string | - | The value of the selected item. Bind with v-model. |
Slots
| Slot | Props | Description |
|---|---|---|
label | any | The group label content. Replaces the label prop. |
description | any | The group description content. Replaces the description prop. |
error | ValidationResult | The error content, rendered while invalid. Replaces the errorMessage prop. |
item-label | { item: RadioGroupItem; index: number; } | The label of each item. Replaces item.label. |
item-description | { item: RadioGroupItem; index: number; } | The description of each item. Replaces item.description. |
indicator | { item: RadioGroupItem; checked: boolean; } | Custom content inside each control. Replaces the default dot. |
Emits
| Event | Payload | Description |
|---|---|---|
update:modelValue | [value: string | undefined] | - |
Accessibility
- The group label is associated via
aria-labelledby; each item's clickable button hasaria-describedbylisting its own description and the group's description/error ids invalidsetsaria-invalid="true"anddata-invalid="true"on the root; the error is rendered via FieldError (role="alert")requiredsetsdata-required="true"on the root- Keyboard: arrow keys move selection between items, following
orientation; Tab moves focus into and out of the group as a single stop









