Radio
A set of round controls for picking exactly one option from a short list.
'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
| Prop | Default | Description |
|---|---|---|
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. |
disabled | false | Blocks every radio in the group. |
Radiobuilt on the Headless UI <Headless.Radio> component | ||
value | - | What the group reports once this radio is picked. |
color | accent | Color of the selected radio. |
disabled | false | Blocks this radio and leaves the others usable. |
RadioFieldbuilt on the Headless UI <Headless.Field> component | ||
disabled | false | Disables the radio and dims its label and description. |
| Prop | Default | Description |
|---|---|---|
RadioGroupbuilt on the Reka UI <RadioGroupRoot> component | ||
v-model | - | Two-way binding of the selected value. |
disabled | false | Blocks every radio in the group. |
Radiobuilt on the Reka UI <RadioGroupItem> component | ||
value | - | What the group reports once this radio is picked. |
color | accent | Color of the selected radio. |
disabled | false | Blocks this radio and leaves the others usable. |
RadioFieldrenders a <div> element | ||
disabled | false | Disables 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:
<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:
'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>