import { Radio, Text } from '@vega-ui/react';
<div role='radiogroup' aria-label='Billing period'>
<label style={{ display: 'flex', alignItems: 'center', gap: 10 }}><Radio name='preview-billing' value='monthly' defaultChecked /><Text size={3}>Monthly</Text></label>
<label style={{ display: 'flex', alignItems: 'center', gap: 10 }}><Radio name='preview-billing' value='yearly' /><Text size={3}>Yearly</Text></label>
</div>- Browser support
- Widely available
- Chrome 111
- Edge 111
- Firefox 113
- Safari 15.4
- Safari on iOS 15.4
- Bundle size
- Oklab and OkLCh — comes from the design tokens
- :focus-visible
- :where()
Summary
Radio is the single-choice selection control for mutually exclusive options that share the same name. It is the most form-like exclusive-choice primitive in the selection family.
Imports
import {
Radio,
type RadioProps,
type RadioSize,
type RadioVariant,
} from '@vega-ui/react';Exported Types
RadioPropsRadioSizeRadioVariant
Minimal Composition
import { Radio } from '@vega-ui/react';
export default function MinimalComposition() {
return (
<div role='radiogroup' aria-label='Theme' style={{ display: 'flex', gap: 'var(--spacing-8)' }}>
<label style={{ display: 'flex', alignItems: 'center', gap: 'var(--spacing-4)' }}>
<Radio name='theme' value='light' defaultChecked />
Light
</label>
<label style={{ display: 'flex', alignItems: 'center', gap: 'var(--spacing-4)' }}>
<Radio name='theme' value='dark' />
DarkRequired Parts
Radio: native radio input surface
Optional Parts
- No extra public parts beyond the input itself
Composition Order
- visible label wrapper
Radio- label text
Variants
- Sizes:
sm,md,lg - Variants:
primary,secondary - States: unchecked, checked, disabled, checked-disabled
Edge Cases
- Radios only behave as one group when the consumer gives related inputs the same
name. - Labels should describe the option, not the action of clicking it.
- Radios are the wrong choice for independent boolean settings.
- If options need richer descriptions or tile-sized targets,
SegmentedControlorCheckboxCardmay be clearer depending on exclusivity.
Common Mistakes
- Using radios without a shared
name. - Using radios for multi-select choices.
- Building a radio row where only the tiny control is clickable.
Examples
Basic
Basic: choice group in a fieldset
Radio is a bare native input[type='radio'], so each one is wrapped in a <label> with its text. Radios with the same name form one group: only one can be checked, and arrow keys move between them. Fieldset with a legend names the group.
import { Fieldset, FieldsetLegend, Radio } from '@vega-ui/react';
const methods = [
{ value: 'standard', label: 'Standard · 5–7 business days · Free' },
{ value: 'express', label: 'Express · 2–3 business days · $9.90' },
{ value: 'overnight', label: 'Overnight · next business day · $24.00' },
];
export default function LabeledRadioOption() {
return (
<Fieldset>
<FieldsetLegend style={{ marginBlockEnd: 'var(--spacing-5)' }}>Shipping method</FieldsetLegend>Controlled/Stateful
Controlled/Stateful: selected value in the parent
The parent stores the selected value and passes checked={cycle === item.value} to every radio; onChange reads event.currentTarget.value. The price below is computed from the same state.
import { useState } from 'react';
import { Fieldset, FieldsetLegend, Radio, Text } from '@vega-ui/react';
const cycles = [
{ value: 'monthly', label: 'Monthly', perMonth: 15 },
{ value: 'yearly', label: 'Yearly (2 months free)', perMonth: 12.5 },
];
export default function ParentOwnedSelectedPlan() {
const [cycle, setCycle] = useState('yearly');
const selected = cycles.find((item) => item.value === cycle) ?? cycles[0];Form/Integration
Form/Integration: required group and FormData
required on the radios makes the whole group required, so the browser blocks submit until one is chosen. FormData.get('size') returns the checked value; reset clears the selection.
import { FormEvent, useState } from 'react';
import { Button, Fieldset, FieldsetLegend, Radio, Text } from '@vega-ui/react';
const sizes = [
{ value: 's-2vcpu-4gb', label: '2 vCPU · 4 GB RAM · $24/mo' },
{ value: 's-4vcpu-8gb', label: '4 vCPU · 8 GB RAM · $48/mo' },
{ value: 's-8vcpu-16gb', label: '8 vCPU · 16 GB RAM · $96/mo' },
];
export default function FormDataSubmission() {
const [result, setResult] = useState('');
Layout/Overlay
Layout/Overlay: choice inside a dialog
The radios live in a form method='dialog': saving reads FormData, updates the value outside and closes the dialog. Reopening starts from the saved value because the content remounts.
import { CSSProperties, FormEvent, useState } from 'react';
import {
Button,
Dialog,
DialogClose,
DialogContent,
DialogTrigger,
Fieldset,
FieldsetLegend,
Heading,
Icon,
IconButton,Error
Error: required group without a selection
The invalid event of the required radios replaces the browser bubble: the error is linked to the Fieldset through aria-describedby, the radios get aria-invalid, and focus moves to the first option. Choosing any option clears the error.
import { FormEvent, useRef, useState } from 'react';
import { Button, Fieldset, FieldsetLegend, HelperText, Radio } from '@vega-ui/react';
const reasons = [
{ value: 'price', label: 'It is too expensive' },
{ value: 'features', label: 'It is missing features I need' },
{ value: 'switching', label: 'I am switching to another product' },
{ value: 'other', label: 'Other' },
];
export default function MissingRequiredRadioSelection() {
const [invalid, setInvalid] = useState(false);Disabled
Disabled: disabled option and disabled group
A single unavailable option is disabled and explains why through aria-describedby. A Fieldset disabled disables every radio inside natively. The label text is dimmed by the example, because Radio only styles the input. Disabled radios are not submitted.
import { Fieldset, FieldsetLegend, HelperText, Radio } from '@vega-ui/react';
export default function DisabledOption() {
return (
<div style={{ display: 'grid', gap: 'var(--spacing-12)', width: '100%' }}>
<Fieldset>
<FieldsetLegend style={{ marginBlockEnd: 'var(--spacing-5)' }}>Data region</FieldsetLegend>
<div style={{ display: 'grid', gap: 'var(--spacing-6)' }}>
<label style={{ display: 'flex', alignItems: 'center', gap: 'var(--spacing-4)' }}>
<Radio name='region' value='eu' defaultChecked />
European Union (Frankfurt)
</label>Edge
Edge: options with long descriptions
When each option has a description, the label is linked with htmlFor/id, the description through aria-describedby, and the radio is aligned to the first line with flex-start and flex-shrink: 0 so long text does not squeeze it.
import { Fieldset, FieldsetLegend, Radio, Text } from '@vega-ui/react';
const policies = [
{
value: 'anyone',
label: 'Anyone with the link',
description: 'People outside your organization can open shared documents without signing in.',
},
{
value: 'organization',
label: 'People in Acme Corp',
description: 'Only signed-in members of your organization can open shared documents.',Edge: repeated groups need unique names
Every table row is its own group, so each row gets its own name built from the row id; a shared name would turn the whole table into one group. Radios without visible text get an aria-label that names both the row and the option.
import { useState } from 'react';
import { Radio, Table, TableBody, TableData, TableHead, TableHeading, TableRow } from '@vega-ui/react';
const members = [
{ id: 'u-201', name: 'Maya Chen' },
{ id: 'u-202', name: 'Lucas Ferreira' },
{ id: 'u-203', name: 'Aisha Okafor' },
];
const roles = ['viewer', 'editor', 'admin'];
export default function RepeatedGroupsUniqueNames() {
const [assigned, setAssigned] = useState<Record<string, string>>({ 'u-201': 'admin', 'u-202': 'editor', 'u-203': 'viewer' });Anatomy
Overview
Radio is a single native input rather than a multi-part compound component. The real composition contract lives in how multiple radios are grouped through shared name and visible labels.
Required Parts
Radio
Required. Renders the native input[type='radio'] and owns checked, disabled, size, and variant behavior.
Optional Parts
Radio does not expose additional public child parts.
Composition Order
- visible label wrapper
Radio- label text
Valid Composition Patterns
Two valid ways to name a radio: wrap it in a <label> together with its text, or give it an id and point a separate Label at it with htmlFor. Both radios share one name, so they form one group.
import { Label, Radio } from '@vega-ui/react';
export default function ValidCompositionPatterns() {
return (
<div style={{ display: 'grid', gap: 'var(--spacing-6)' }}>
<label style={{ display: 'flex', alignItems: 'center', gap: 'var(--spacing-4)' }}>
<Radio name='density' value='comfortable' defaultChecked />
Comfortable
</label>
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--spacing-4)' }}>
<Radio id='density-compact' name='density' value='compact' />
<Label htmlFor='density-compact'>Compact</Label>Invalid Composition Patterns
Radios without a shared name
This breaks mutually exclusive group behavior.
Radio used as a standalone boolean toggle
If the choice is independent rather than exclusive, use Checkbox or Switch.
Label text detached from the control
This makes the radio harder to understand and harder to click.