RadioGroup

Radio group for selecting a single option from a list

Usage

<script setup lang="ts">
import { RadioGroup } from '@hareui/vue'
</script>
Plan selectionChoose the plan that suits you best
Includes 100 messages per month
Includes 200 messages per month
Unlimited messages
<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 keydata-slotElement
baseradio-grouproot element
itemradioper-item row (<div>)
contentradio-contentthe clickable control + label button
controlradio-controlcircular control
indicatorradio-indicatorinner 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 string

Examples

Horizontal Orientation

For side projects
Advanced reporting
Up to 10 teammates
<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 cases
  • secondary - Lower emphasis variant, suitable for use alongside surface-style backgrounds

Primary variant

Standard styling with default background
Another option with primary styling

Secondary variant

Lower emphasis variant for use in surfaces
Another option with secondary styling
<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.

Plan selectionChoose the plan that suits you best
Includes 100 messages per month
Includes 200 messages per month
Unlimited messages
<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

Subscription planPlan changes are temporarily paused while we roll out updates.
For side projects and small teams
Advanced reporting and analytics
Share access with up to 10 teammates
<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.

Subscription plan
For side projects and small teams
Advanced reporting and analytics
Share access with up to 10 teammates

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.

Subscription plan
For side projects and small teams
Advanced reporting and analytics
Share access with up to 10 teammates

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.

Subscription plan
For side projects and small teams
Advanced reporting and analytics
Share access with up to 10 teammates
<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

Delivery method
Payment method
<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

Plan selectionChoose the plan that suits you best
Includes 100 messages per month
Includes 200 messages per month
Unlimited messages
<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:.

Billing cycleChoose how often you are charged.
<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 + label
  • control → [data-slot="radio-control"] – circular control
  • indicator → [data-slot="radio-indicator"] – inner dot, empty by default; fill with #indicator for 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"] (group disabled or item.disabled)
  • Invalid: [data-invalid="true"] on [data-slot="radio"] when the group invalid is true
  • Read only: [data-readonly="true"] and aria-readonly="true" on the root
  • Required: [data-required="true"] on the root

API Reference

Props

PropTypeDefaultDescription
itemsRadioGroupItem[][]The radio items.
defaultValuestring-The value selected initially when uncontrolled.
labelstring-The group label.
descriptionstring-Help text rendered below the group label.
errorMessagestring-Error message rendered below the items while invalid. Defaults to the validation errors.
invalidbooleanundefinedWhether the group is invalid. Overrides validation when set.
disabledbooleanfalseWhether the whole group is disabled.
readonlybooleanfalseWhether the group is read only: items stay focusable and announced, but the selection can't change.
requiredbooleanfalseWhether a selection is required before form submission.
validateValidateFn<string>-Validates the selected value (not called while nothing is selected). Return an error message (or several) when invalid.
validationBehaviorValidationBehavior'native'native blocks form submission and shows errors on change or submit; aria shows errors in realtime. Defaults to the surrounding Form.
namestring-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.
idstring-Base id of the group; item controls get ${id}-${index}. Generated when omitted.
uiComponentSlots<{ 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.
modelValuestring-The value of the selected item. Bind with v-model.

Slots

SlotPropsDescription
labelanyThe group label content. Replaces the label prop.
descriptionanyThe group description content. Replaces the description prop.
errorValidationResultThe 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

EventPayloadDescription
update:modelValue[value: string | undefined]-

Accessibility

  • The group label is associated via aria-labelledby; each item's clickable button has aria-describedby listing its own description and the group's description/error ids
  • invalid sets aria-invalid="true" and data-invalid="true" on the root; the error is rendered via FieldError (role="alert")
  • required sets data-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

On this page

No Headings