Skip to content
  1. Selection
  2. Radio button

Radio

A set of round controls for picking exactly one option from a short list.

Payout schedule

Decide how often northwind moves its balance to your bank account.

Each payment is paid out two business days after it settles.

One transfer covers the previous seven days.

The balance stays put until someone starts a payout.

'use client'

import { useState } from 'react'
import { Description, Fieldset, Label, Legend } from '@/components/fieldset'
import { Radio, RadioField, RadioGroup } from '@/components/radio'
import { Text } from '@/components/text'

export default function Example() {
  let [schedule, setSchedule] = useState('weekly')

  return (
    <Fieldset>
      <Legend>Payout schedule</Legend>
      <Text>Decide how often northwind moves its balance to your bank account.</Text>
      <RadioGroup name="schedule" value={schedule} onChange={setSchedule}>
        <RadioField>
          <Radio value="daily" />
          <Label>Daily</Label>
          <Description>Each payment is paid out two business days after it settles.</Description>
        </RadioField>
        <RadioField>
          <Radio value="weekly" />
          <Label>Weekly, on Mondays</Label>
          <Description>One transfer covers the previous seven days.</Description>
        </RadioField>
        <RadioField>
          <Radio value="manual" />
          <Label>On request</Label>
          <Description>The balance stays put until someone starts a payout.</Description>
        </RadioField>
      </RadioGroup>
    </Fieldset>
  )
}
<script setup lang="ts">
import Description from '@/components/description.vue'
import Fieldset from '@/components/fieldset.vue'
import Label from '@/components/label.vue'
import Legend from '@/components/legend.vue'
import Radio from '@/components/radio.vue'
import RadioField from '@/components/radio-field.vue'
import RadioGroup from '@/components/radio-group.vue'
import Text from '@/components/text.vue'
import { ref } from 'vue'

const schedule = ref('weekly')
</script>

<template>
  <Fieldset>
    <Legend>Payout schedule</Legend>
    <Text>Decide how often northwind moves its balance to your bank account.</Text>
    <RadioGroup name="schedule" v-model="schedule">
      <RadioField>
        <Radio value="daily" />
        <Label>Daily</Label>
        <Description>Each payment is paid out two business days after it settles.</Description>
      </RadioField>
      <RadioField>
        <Radio value="weekly" />
        <Label>Weekly, on Mondays</Label>
        <Description>One transfer covers the previous seven days.</Description>
      </RadioField>
      <RadioField>
        <Radio value="manual" />
        <Label>On request</Label>
        <Description>The balance stays put until someone starts a payout.</Description>
      </RadioField>
    </RadioGroup>
  </Fieldset>
</template>

Installation

Copy these files into the folder where you keep your components. They import each other with relative paths, so the folder can live anywhere. The packages they rely on are listed on the Installation page.

import * as Headless from '@headlessui/react'
import clsx from 'clsx'
import type React from 'react'

type Colors = {
  background: string
  border: string
  dot: string
}

type RadioColors = Colors & { dark?: Partial<Colors> }

const withOpacity = (color: string, percent: number) => `color-mix(in oklab, ${color} ${percent}%, transparent)`

const white = 'var(--color-white)'

function whiteDot(background: string, border: string, borderOpacity = 90): RadioColors {
  return { background, border: withOpacity(border, borderOpacity), dot: white }
}

function darkDot(background: string, border: string, dot: string): RadioColors {
  return { background, border: withOpacity(border, 80), dot }
}

const darkRadio = whiteDot('var(--color-zinc-900)', 'var(--color-zinc-950)')

const lightRadio = {
  background: white,
  border: withOpacity('var(--color-zinc-950)', 15),
  dot: 'var(--color-zinc-900)',
} satisfies RadioColors

const colors = {
  'dark/zinc': { ...darkRadio, dark: { background: 'var(--color-zinc-600)' } },
  'dark/white': { ...darkRadio, dark: lightRadio },
  white: lightRadio,
  dark: darkRadio,
  zinc: whiteDot('var(--color-zinc-600)', 'var(--color-zinc-700)'),
  red: whiteDot('var(--color-red-600)', 'var(--color-red-700)'),
  orange: whiteDot('var(--color-orange-500)', 'var(--color-orange-600)'),
  amber: darkDot('var(--color-amber-400)', 'var(--color-amber-500)', 'var(--color-amber-950)'),
  yellow: darkDot('var(--color-yellow-300)', 'var(--color-yellow-400)', 'var(--color-yellow-950)'),
  lime: darkDot('var(--color-lime-300)', 'var(--color-lime-400)', 'var(--color-lime-950)'),
  green: whiteDot('var(--color-green-600)', 'var(--color-green-700)'),
  emerald: whiteDot('var(--color-emerald-600)', 'var(--color-emerald-700)'),
  teal: whiteDot('var(--color-teal-600)', 'var(--color-teal-700)'),
  cyan: darkDot('var(--color-cyan-300)', 'var(--color-cyan-400)', 'var(--color-cyan-950)'),
  sky: whiteDot('var(--color-sky-500)', 'var(--color-sky-600)', 80),
  blue: whiteDot('var(--color-blue-600)', 'var(--color-blue-700)'),
  accent: whiteDot('var(--accent-600,var(--color-blue-600))', 'var(--accent-700,var(--color-blue-700))'),
  indigo: whiteDot('var(--color-indigo-500)', 'var(--color-indigo-600)'),
  violet: whiteDot('var(--color-violet-500)', 'var(--color-violet-600)'),
  purple: whiteDot('var(--color-purple-500)', 'var(--color-purple-600)'),
  fuchsia: whiteDot('var(--color-fuchsia-500)', 'var(--color-fuchsia-600)'),
  pink: whiteDot('var(--color-pink-500)', 'var(--color-pink-600)'),
  rose: whiteDot('var(--color-rose-500)', 'var(--color-rose-600)'),
} satisfies Record<string, RadioColors>

type Color = keyof typeof colors

function cssVariables(colors: RadioColors): React.CSSProperties {
  let variables: Record<string, string | undefined> = {
    '--radio-fill': colors.background,
    '--radio-border': colors.border,
    '--radio-dot': colors.dot,
    '--radio-dark-fill': colors.dark?.background,
    '--radio-dark-border': colors.dark?.border,
    '--radio-dark-dot': colors.dark?.dot,
  }
  return Object.fromEntries(Object.entries(variables).filter(([, value]) => value !== undefined)) as React.CSSProperties
}

const themeColors = [
  '[--checked-background:var(--radio-fill)] dark:[--checked-background:var(--radio-dark-fill,var(--radio-fill))]',
  '[--checked-border:var(--radio-border)] dark:[--checked-border:var(--radio-dark-border,var(--radio-border))]',
  '[--checked-dot-color:var(--radio-dot)] dark:[--checked-dot-color:var(--radio-dark-dot,var(--radio-dot))]',
]

const layout = 'relative isolate flex shrink-0 rounded-full size-4.75 sm:size-4.25'

const background = [
  'before:absolute before:-z-10 before:inset-0 before:rounded-full',
  'before:shadow-sm before:bg-white',
]

const highlight = [
  'after:absolute after:inset-0 after:rounded-full',
  'after:shadow-[inset_0_1px_--theme(--color-white/15%)]',
]

const border = 'border border-zinc-950/15 group-data-hover:border-zinc-950/30'

const checked = [
  'group-data-checked:bg-(--checked-border) group-data-checked:before:bg-(--checked-background)',
  'group-data-checked:border-transparent group-data-hover:group-data-checked:border-transparent',
]

const dark = [
  'dark:bg-white/5 dark:border-white/15 dark:group-data-hover:border-white/30',
  'dark:before:hidden dark:after:hidden dark:after:-inset-px dark:after:rounded-full',
]

const darkChecked = [
  'dark:group-data-checked:bg-(--checked-background) dark:group-data-checked:after:block',
  'dark:group-data-checked:border-white/5 dark:group-data-hover:group-data-checked:border-white/5',
]

const dotColor = [
  '[--dot-color:transparent] group-data-hover:[--dot-color:var(--color-zinc-900)]/10',
  'dark:group-data-hover:[--dot-color:var(--color-zinc-700)]',
  'group-data-checked:[--dot-color:var(--checked-dot-color)]',
  'group-data-hover:group-data-checked:[--dot-color:var(--checked-dot-color)]',
  'dark:group-data-hover:group-data-checked:[--dot-color:var(--checked-dot-color)]',
]

const focus = 'group-data-focus:outline group-data-focus:outline-accent-500 group-data-focus:outline-2 group-data-focus:outline-offset-2'

const disabled = [
  'group-data-disabled:opacity-50 group-data-disabled:bg-zinc-950/5 group-data-disabled:border-zinc-950/25',
  'group-data-disabled:before:bg-transparent group-data-disabled:[--checked-dot-color:var(--color-zinc-950)]/50',
  'dark:group-data-disabled:bg-white/2.5 dark:group-data-disabled:border-white/20',
  'dark:group-data-disabled:[--checked-dot-color:var(--color-white)]/50 dark:group-data-checked:group-data-disabled:after:hidden',
]

const control = [layout, background, highlight, border, checked, dark, darkChecked, dotColor, focus, disabled, themeColors]

const dot = [
  'size-full rounded-full bg-(--dot-color) bg-clip-padding border-[4.5px] border-transparent',
  'forced-colors:border-[Canvas]',
  'forced-colors:group-data-checked:border-[Highlight]',
]

export function RadioGroup({
  className,
  ...props
}: { className?: string } & Omit<Headless.RadioGroupProps, 'as' | 'className'>) {
  return (
    <Headless.RadioGroup
      data-slot="control"
      {...props}
      className={clsx(
        className,
        'space-y-3 has-data-[slot=description]:space-y-6',
        '**:data-[slot=label]:font-normal',
        'has-data-[slot=description]:**:data-[slot=label]:font-medium'
      )}
    />
  )
}

const fieldGrid = 'grid gap-x-4 gap-y-1 grid-cols-[1.125rem_1fr] sm:grid-cols-[1rem_1fr]'

const fieldColumns = '*:data-[slot=control]:col-start-1 *:data-[slot=label]:col-start-2 *:data-[slot=description]:col-start-2'

const fieldRows = '*:data-[slot=control]:row-start-1 *:data-[slot=label]:row-start-1 *:data-[slot=description]:row-start-2'

const controlOffset = '*:data-[slot=control]:mt-0.75 sm:*:data-[slot=control]:mt-1'

export function RadioField({
  className,
  ...props
}: { className?: string } & Omit<Headless.FieldProps, 'as' | 'className'>) {
  return (
    <Headless.Field
      data-slot="field"
      {...props}
      className={clsx(className, fieldGrid, fieldColumns, fieldRows, controlOffset, 'has-data-[slot=description]:**:data-[slot=label]:font-medium')}
    />
  )
}

export function Radio({
  color = 'accent',
  className,
  ...props
}: { color?: Color; className?: string } & Omit<Headless.RadioProps, 'as' | 'className' | 'children'>) {
  return (
    <Headless.Radio data-slot="control" {...props} className={clsx(className, 'group inline-flex focus:outline-hidden')}>
      <span style={cssVariables(colors[color])} className={clsx(control)}>
        <span className={clsx(dot)} />
      </span>
    </Headless.Radio>
  )
}
<script setup lang="ts">
import { RadioGroupItem } from 'reka-ui'
import { computed, inject, useAttrs, type CSSProperties } from 'vue'
import { useDisabled, useFieldControl } from './fields'
import { vInteractions } from './interactions'
import { RadioGroupValueKey } from './radio-group.vue'

defineOptions({ inheritAttrs: false })

type Colors = {
  background: string
  border: string
  dot: string
}

type RadioColors = Colors & { dark?: Partial<Colors> }

const withOpacity = (color: string, percent: number) => `color-mix(in oklab, ${color} ${percent}%, transparent)`

const white = 'var(--color-white)'

function whiteDot(background: string, border: string, borderOpacity = 90): RadioColors {
  return { background, border: withOpacity(border, borderOpacity), dot: white }
}

function darkDot(background: string, border: string, dot: string): RadioColors {
  return { background, border: withOpacity(border, 80), dot }
}

const darkRadio = whiteDot('var(--color-zinc-900)', 'var(--color-zinc-950)')

const lightRadio = {
  background: white,
  border: withOpacity('var(--color-zinc-950)', 15),
  dot: 'var(--color-zinc-900)',
} satisfies RadioColors

const colors = {
  'dark/zinc': { ...darkRadio, dark: { background: 'var(--color-zinc-600)' } },
  'dark/white': { ...darkRadio, dark: lightRadio },
  white: lightRadio,
  dark: darkRadio,
  zinc: whiteDot('var(--color-zinc-600)', 'var(--color-zinc-700)'),
  red: whiteDot('var(--color-red-600)', 'var(--color-red-700)'),
  orange: whiteDot('var(--color-orange-500)', 'var(--color-orange-600)'),
  amber: darkDot('var(--color-amber-400)', 'var(--color-amber-500)', 'var(--color-amber-950)'),
  yellow: darkDot('var(--color-yellow-300)', 'var(--color-yellow-400)', 'var(--color-yellow-950)'),
  lime: darkDot('var(--color-lime-300)', 'var(--color-lime-400)', 'var(--color-lime-950)'),
  green: whiteDot('var(--color-green-600)', 'var(--color-green-700)'),
  emerald: whiteDot('var(--color-emerald-600)', 'var(--color-emerald-700)'),
  teal: whiteDot('var(--color-teal-600)', 'var(--color-teal-700)'),
  cyan: darkDot('var(--color-cyan-300)', 'var(--color-cyan-400)', 'var(--color-cyan-950)'),
  sky: whiteDot('var(--color-sky-500)', 'var(--color-sky-600)', 80),
  blue: whiteDot('var(--color-blue-600)', 'var(--color-blue-700)'),
  accent: whiteDot('var(--accent-600,var(--color-blue-600))', 'var(--accent-700,var(--color-blue-700))'),
  indigo: whiteDot('var(--color-indigo-500)', 'var(--color-indigo-600)'),
  violet: whiteDot('var(--color-violet-500)', 'var(--color-violet-600)'),
  purple: whiteDot('var(--color-purple-500)', 'var(--color-purple-600)'),
  fuchsia: whiteDot('var(--color-fuchsia-500)', 'var(--color-fuchsia-600)'),
  pink: whiteDot('var(--color-pink-500)', 'var(--color-pink-600)'),
  rose: whiteDot('var(--color-rose-500)', 'var(--color-rose-600)'),
} satisfies Record<string, RadioColors>

type Color = keyof typeof colors

function cssVariables(colors: RadioColors): CSSProperties {
  let variables: Record<string, string | undefined> = {
    '--radio-fill': colors.background,
    '--radio-border': colors.border,
    '--radio-dot': colors.dot,
    '--radio-dark-fill': colors.dark?.background,
    '--radio-dark-border': colors.dark?.border,
    '--radio-dark-dot': colors.dark?.dot,
  }
  return Object.fromEntries(Object.entries(variables).filter(([, value]) => value !== undefined)) as CSSProperties
}

const themeColors = [
  '[--checked-background:var(--radio-fill)] dark:[--checked-background:var(--radio-dark-fill,var(--radio-fill))]',
  '[--checked-border:var(--radio-border)] dark:[--checked-border:var(--radio-dark-border,var(--radio-border))]',
  '[--checked-dot-color:var(--radio-dot)] dark:[--checked-dot-color:var(--radio-dark-dot,var(--radio-dot))]',
]

const layout = 'relative isolate flex shrink-0 rounded-full size-4.75 sm:size-4.25'

const background = [
  'before:absolute before:-z-10 before:inset-0 before:rounded-full',
  'before:shadow-sm before:bg-white',
]

const highlight = [
  'after:absolute after:inset-0 after:rounded-full',
  'after:shadow-[inset_0_1px_--theme(--color-white/15%)]',
]

const border = 'border border-zinc-950/15 group-data-hover:border-zinc-950/30'

const checked = [
  'group-data-checked:bg-(--checked-border) group-data-checked:before:bg-(--checked-background)',
  'group-data-checked:border-transparent group-data-hover:group-data-checked:border-transparent',
]

const dark = [
  'dark:bg-white/5 dark:border-white/15 dark:group-data-hover:border-white/30',
  'dark:before:hidden dark:after:hidden dark:after:-inset-px dark:after:rounded-full',
]

const darkChecked = [
  'dark:group-data-checked:bg-(--checked-background) dark:group-data-checked:after:block',
  'dark:group-data-checked:border-white/5 dark:group-data-hover:group-data-checked:border-white/5',
]

const dotColor = [
  '[--dot-color:transparent] group-data-hover:[--dot-color:var(--color-zinc-900)]/10',
  'dark:group-data-hover:[--dot-color:var(--color-zinc-700)]',
  'group-data-checked:[--dot-color:var(--checked-dot-color)]',
  'group-data-hover:group-data-checked:[--dot-color:var(--checked-dot-color)]',
  'dark:group-data-hover:group-data-checked:[--dot-color:var(--checked-dot-color)]',
]

const focus = 'group-data-focus:outline group-data-focus:outline-accent-500 group-data-focus:outline-2 group-data-focus:outline-offset-2'

const disabled = [
  'group-data-disabled:opacity-50 group-data-disabled:bg-zinc-950/5 group-data-disabled:border-zinc-950/25',
  'group-data-disabled:before:bg-transparent group-data-disabled:[--checked-dot-color:var(--color-zinc-950)]/50',
  'dark:group-data-disabled:bg-white/2.5 dark:group-data-disabled:border-white/20',
  'dark:group-data-disabled:[--checked-dot-color:var(--color-white)]/50 dark:group-data-checked:group-data-disabled:after:hidden',
]

const control = [layout, background, highlight, border, checked, dark, darkChecked, dotColor, focus, disabled, themeColors]

const dot = [
  'size-full rounded-full bg-(--dot-color) bg-clip-padding border-[4.5px] border-transparent',
  'forced-colors:border-[Canvas]',
  'forced-colors:group-data-checked:border-[Highlight]',
]

const props = withDefaults(
  defineProps<{
    value: string
    color?: Color
    disabled?: boolean
  }>(),
  { color: 'accent' }
)

const attrs = useAttrs()
const fieldControl = useFieldControl(attrs)

const groupValue = inject(RadioGroupValueKey, null)
const isChecked = computed(() => groupValue?.value === props.value)

const isDisabled = useDisabled(() => props.disabled)

const innerAttrs = computed(() => {
  const { class: _class, ...rest } = attrs
  return rest
})
</script>

<template>
  <RadioGroupItem
    v-bind="innerAttrs"
    v-interactions
    data-slot="control"
    :data-checked="isChecked ? '' : undefined"
    :value="value"
    :disabled="isDisabled"
    :id="fieldControl.id.value"
    :aria-describedby="fieldControl.describedBy.value"
    :class="[attrs.class, 'group inline-flex focus:outline-hidden']"
  >
    <span :style="cssVariables(colors[color])" :class="control">
      <span :class="dot" />
    </span>
  </RadioGroupItem>
</template>

Component API

PropDefaultDescription
RadioGroupbuilt on the Headless UI <Headless.RadioGroup> component
value-Value of the selected radio when you control it yourself.
onChange-Receives the value of the radio that was just picked.
disabledfalseBlocks every radio in the group.
Radiobuilt on the Headless UI <Headless.Radio> component
value-What the group reports once this radio is picked.
coloraccentColor of the selected radio.
disabledfalseBlocks this radio and leaves the others usable.
RadioFieldbuilt on the Headless UI <Headless.Field> component
disabledfalseDisables the radio and dims its label and description.
PropDefaultDescription
RadioGroupbuilt on the Reka UI <RadioGroupRoot> component
v-model-Two-way binding of the selected value.
disabledfalseBlocks every radio in the group.
Radiobuilt on the Reka UI <RadioGroupItem> component
value-What the group reports once this radio is picked.
coloraccentColor of the selected radio.
disabledfalseBlocks this radio and leaves the others usable.
RadioFieldrenders a <div> element
disabledfalseDisables the radio and dims its label and description.

Usage

Radio buttons keep every option in view, which helps when people need to compare them before choosing. They suit short lists of two to five mutually exclusive answers. Past that, a listbox saves space, and when each option needs a price or a longer pitch, radio cards give it room.

Pick one option from a short list

A RadioGroup holds the selection, and each RadioField pairs a Radio with its Label so the text is clickable too. Name the group with aria-label when nothing on screen does:

'use client'

import { useState } from 'react'
import { Label } from '@/components/fieldset'
import { Radio, RadioField, RadioGroup } from '@/components/radio'

export default function Example() {
  let [billing, setBilling] = useState('monthly')

  return (
    <RadioGroup name="interval" value={billing} onChange={setBilling} aria-label="Billing interval">
      <RadioField>
        <Radio value="monthly" />
        <Label>Monthly</Label>
      </RadioField>
      <RadioField>
        <Radio value="yearly" />
        <Label>Yearly</Label>
      </RadioField>
    </RadioGroup>
  )
}
<script setup lang="ts">
import Label from '@/components/label.vue'
import Radio from '@/components/radio.vue'
import RadioField from '@/components/radio-field.vue'
import RadioGroup from '@/components/radio-group.vue'
import { ref } from 'vue'

const billing = ref('monthly')
</script>

<template>
  <RadioGroup name="interval" v-model="billing" aria-label="Billing interval">
    <RadioField>
      <Radio value="monthly" />
      <Label>Monthly</Label>
    </RadioField>
    <RadioField>
      <Radio value="yearly" />
      <Label>Yearly</Label>
    </RadioField>
  </RadioGroup>
</template>

Spell out what each choice does

When the labels alone leave room for doubt, add a Description to each field. Labels switch to medium weight so they still stand out:

The customer pays for the rest of the current period today.

The difference shows up as a line on the upcoming renewal.

<RadioGroup name="proration" value={proration} onChange={setProration} aria-label="Plan change billing">
  <RadioField>
    <Radio value="now" />
    <Label>Charge the difference now</Label>
    <Description>The customer pays for the rest of the current period today.</Description>
  </RadioField>
  <RadioField>
    <Radio value="next_invoice" />
    <Label>Add it to the next invoice</Label>
    <Description>The difference shows up as a line on the upcoming renewal.</Description>
  </RadioField>
</RadioGroup>
<RadioGroup name="proration" v-model="proration" aria-label="Plan change billing">
  <RadioField>
    <Radio value="now" />
    <Label>Charge the difference now</Label>
    <Description>The customer pays for the rest of the current period today.</Description>
  </RadioField>
  <RadioField>
    <Radio value="next_invoice" />
    <Label>Add it to the next invoice</Label>
    <Description>The difference shows up as a line on the upcoming renewal.</Description>
  </RadioField>
</RadioGroup>

Give the group a heading

Inside a Fieldset, the Legend names the whole group and a Text can add one line of context before the options:

Payout schedule

Decide how often northwind moves its balance to your bank account.

Each payment is paid out two business days after it settles.

One transfer covers the previous seven days.

The balance stays put until someone starts a payout.

<Fieldset>
  <Legend>Payout schedule</Legend>
  <Text>Decide how often northwind moves its balance to your bank account.</Text>
  <RadioGroup name="schedule" value={schedule} onChange={setSchedule}>
    <RadioField>
      <Radio value="daily" />
      <Label>Daily</Label>
      <Description>Each payment is paid out two business days after it settles.</Description>
    </RadioField>
    {/* ... */}
  </RadioGroup>
</Fieldset>
<Fieldset>
  <Legend>Payout schedule</Legend>
  <Text>Decide how often northwind moves its balance to your bank account.</Text>
  <RadioGroup name="schedule" v-model="schedule">
    <RadioField>
      <Radio value="daily" />
      <Label>Daily</Label>
      <Description>Each payment is paid out two business days after it settles.</Description>
    </RadioField>
    <!-- ... -->
  </RadioGroup>
</Fieldset>

States

Preselect the usual answer

Set defaultValue on the RadioGroup when most accounts pick the same option and you do not need to hold the value in state:

Start the v-model ref at the value most accounts pick, so the group never renders empty:

<RadioGroup name="tax" defaultValue="exclusive" aria-label="Tax on prices">
  <RadioField>
    <Radio value="exclusive" />
    <Label>Add tax on top of prices</Label>
  </RadioField>
  <RadioField>
    <Radio value="inclusive" />
    <Label>Prices already include tax</Label>
  </RadioField>
</RadioGroup>
<script setup lang="ts">
import { ref } from 'vue'

const tax = ref('exclusive')
</script>

<template>
  <RadioGroup name="tax" v-model="tax" aria-label="Tax on prices">
    <RadioField>
      <Radio value="exclusive" />
      <Label>Add tax on top of prices</Label>
    </RadioField>
    <RadioField>
      <Radio value="inclusive" />
      <Label>Prices already include tax</Label>
    </RadioField>
  </RadioGroup>
</template>

Show an option that is not available yet

disabled on a RadioField blocks that option and dims its text while the rest of the group stays usable. Say why in the description:

Arrives in two business days, no fee.

Needs a verified debit card on file.

<RadioGroup name="speed" value={speed} onChange={setSpeed} aria-label="Payout speed">
  <RadioField>
    <Radio value="standard" />
    <Label>Standard</Label>
    <Description>Arrives in two business days, no fee.</Description>
  </RadioField>
  <RadioField disabled>
    <Radio value="instant" />
    <Label>Instant</Label>
    <Description>Needs a verified debit card on file.</Description>
  </RadioField>
</RadioGroup>
<RadioGroup name="speed" v-model="speed" aria-label="Payout speed">
  <RadioField>
    <Radio value="standard" />
    <Label>Standard</Label>
    <Description>Arrives in two business days, no fee.</Description>
  </RadioField>
  <RadioField disabled>
    <Radio value="instant" />
    <Label>Instant</Label>
    <Description>Needs a verified debit card on file.</Description>
  </RadioField>
</RadioGroup>

Put disabled on the RadioGroup instead when the whole choice is locked.

Forms

Keep the selection in your own state

Pass value and onChange when the rest of the screen reacts to the choice, like a price summary:

Bind a ref with v-model when the rest of the screen reacts to the choice, like a price summary:

Billed monthly

'use client'

import { useState } from 'react'
import { Label } from '@/components/fieldset'
import { Radio, RadioField, RadioGroup } from '@/components/radio'

export default function Example() {
  let [billing, setBilling] = useState('monthly')

  return (
    <>
      <RadioGroup name="interval" value={billing} onChange={setBilling} aria-label="Billing interval">
        <RadioField>
          <Radio value="monthly" />
          <Label>Monthly</Label>
        </RadioField>
        <RadioField>
          <Radio value="yearly" />
          <Label>Yearly</Label>
        </RadioField>
      </RadioGroup>
      <p>Billed {billing}</p>
    </>
  )
}
<script setup lang="ts">
import Label from '@/components/label.vue'
import Radio from '@/components/radio.vue'
import RadioField from '@/components/radio-field.vue'
import RadioGroup from '@/components/radio-group.vue'
import { ref } from 'vue'

const billing = ref('monthly')
</script>

<template>
  <RadioGroup name="interval" v-model="billing" aria-label="Billing interval">
    <RadioField>
      <Radio value="monthly" />
      <Label>Monthly</Label>
    </RadioField>
    <RadioField>
      <Radio value="yearly" />
      <Label>Yearly</Label>
    </RadioField>
  </RadioGroup>
  <p>Billed {{ billing }}</p>
</template>

Customization

Change the selected color

Radios follow your theme accent, and fall back to blue when none is set. Pass the same color to every Radio in a group so the selection looks the same wherever it lands:

<RadioField>
  <Radio color="teal" value="monthly" />
  <Label>Monthly</Label>
</RadioField>
<RadioField>
  <Radio color="teal" value="yearly" />
  <Label>Yearly</Label>
</RadioField>
<RadioField>
  <Radio color="teal" value="monthly" />
  <Label>Monthly</Label>
</RadioField>
<RadioField>
  <Radio color="teal" value="yearly" />
  <Label>Yearly</Label>
</RadioField>

Available colors

dark/zinc and dark/white swap shades between light and dark mode. The other 20 names keep the same color in both:

<Radio color="dark/zinc" value="dark/zinc" />
<Radio color="dark/white" value="dark/white" />
<Radio color="teal" value="teal" />

Lay the radios out in a row

For a compact scale, drop RadioField and arrange each Radio yourself. A native fieldset and legend name the group, and each radio gets an aria-label since its number alone is not a name:

Webhook retry attempts
0
1
3
5
8
'use client'

import { useState } from 'react'
import { Radio, RadioGroup } from '@/components/radio'

let attempts = ['0', '1', '3', '5', '8']

export default function Example() {
  let [retries, setRetries] = useState('3')

  return (
    <fieldset>
      <legend className="text-base/6 font-medium text-zinc-950 sm:text-sm/6 dark:text-white">
        Webhook retry attempts
      </legend>
      <RadioGroup name="retries" value={retries} onChange={setRetries} className="mt-4 flex gap-6 sm:gap-8">
        {attempts.map((count) => (
          <div key={count} className="flex items-center gap-2">
            <Radio value={count} aria-label={`${count} attempts`} />
            <span className="text-base/6 text-zinc-950 select-none sm:text-sm/6 dark:text-white">{count}</span>
          </div>
        ))}
      </RadioGroup>
    </fieldset>
  )
}
<script setup lang="ts">
import Radio from '@/components/radio.vue'
import RadioGroup from '@/components/radio-group.vue'
import { ref } from 'vue'

const attempts = ['0', '1', '3', '5', '8']
const retries = ref('3')
</script>

<template>
  <fieldset>
    <legend class="text-base/6 font-medium text-zinc-950 sm:text-sm/6 dark:text-white">
      Webhook retry attempts
    </legend>
    <RadioGroup name="retries" v-model="retries" class="mt-4 flex gap-6 sm:gap-8">
      <div v-for="count in attempts" :key="count" class="flex items-center gap-2">
        <Radio :value="count" :aria-label="`${count} attempts`" />
        <span class="text-base/6 text-zinc-950 select-none sm:text-sm/6 dark:text-white">{{ count }}</span>
      </div>
    </RadioGroup>
  </fieldset>
</template>