Перейти к содержимому
v3.0.0
English
Русский
Документация

Radio

Radio — контрол выбора одного варианта из взаимоисключающих, у которых общий name.
radio.tsx · 6 строк
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>
Поддержка браузеров
Широко доступно
  • Chrome 111
  • Edge 111
  • Firefox 113
  • Safari 15.4
  • Safari на iOS 15.4
Размер в бандле
История версий
Добавлен в v1.0.0, последнее изменение в v2.3.1

Кратко

Radio — контрол выбора одного варианта из взаимоисключающих, у которых общий name. Это наиболее близкий к формам примитив взаимоисключающего выбора в семействе контролов выбора.

Импорты

import {
  Radio,
  type RadioProps,
  type RadioSize,
  type RadioVariant,
} from '@vega-ui/react';

Экспортируемые типы

  • RadioProps
  • RadioSize
  • RadioVariant

Минимальная композиция

minimal-composition.tsx · 16 строк
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' />
        Dark
      </label>
    </div>
  );
}

Обязательные части

  • Radio: нативный input радиокнопки

Необязательные части

  • Кроме самого input, публичных частей нет

Порядок композиции

  1. видимая обёртка-подпись
  2. Radio
  3. текст подписи

Варианты

  • Размеры: sm, md, lg
  • Варианты: primary, secondary
  • Состояния: не отмечено, отмечено, отключено, отмечено и отключено

Пограничные случаи

  • Радиокнопки ведут себя как одна группа, только если потребитель задаёт связанным input одинаковый name.
  • Подписи должны описывать вариант, а не действие нажатия на него.
  • Радиокнопки не подходят для независимых булевых настроек.
  • Если вариантам нужны более подробные описания или области клика размером с плитку, понятнее может быть SegmentedControl или CheckboxCard — в зависимости от взаимоисключаемости.

Частые ошибки

  • Радиокнопки используются без общего name.
  • Радиокнопки используются для множественного выбора.
  • Строка с радиокнопкой, где кликабелен только крошечный контрол.

Примеры

Базовый

Базовый: группа выбора в fieldset

Radio — это голый нативный input[type='radio'], поэтому каждый оборачивается в <label> со своим текстом. Радиокнопки с одинаковым name образуют группу: отмечена может быть только одна, а стрелки перемещают между ними. Fieldset с легендой даёт группе имя.

labeled-radio-option.tsx · 23 строки
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>
      <div style={{ display: 'grid', gap: 'var(--spacing-6)' }}>
        {methods.map((method) => (
          <label key={method.value} style={{ display: 'flex', alignItems: 'center', gap: 'var(--spacing-4)' }}>
            <Radio name='shipping' value={method.value} defaultChecked={method.value === 'standard'} />
            {method.label}
          </label>
        ))}
      </div>
    </Fieldset>
  );
}

Контролируемый/с состоянием

Контролируемый/с состоянием: выбранное значение в родителе

Родитель хранит выбранное значение и передаёт каждой радиокнопке checked={cycle === item.value}; onChange читает event.currentTarget.value. Цена ниже вычисляется из того же состояния.

parent-owned-selected-plan.tsx · 39 строк
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];
  const seats = 8;
  const total = selected.perMonth * seats;

  return (
    <div style={{ display: 'grid', gap: 'var(--spacing-8)', width: '100%' }}>
      <Fieldset>
        <FieldsetLegend style={{ marginBlockEnd: 'var(--spacing-5)' }}>Billing cycle</FieldsetLegend>
        <div style={{ display: 'grid', gap: 'var(--spacing-6)' }}>
          {cycles.map((item) => (
            <label key={item.value} style={{ display: 'flex', alignItems: 'center', gap: 'var(--spacing-4)' }}>
              <Radio
                name='billing-cycle'
                value={item.value}
                checked={cycle === item.value}
                onChange={(event) => setCycle(event.currentTarget.value)}
              />
              {item.label}
            </label>
          ))}
        </div>
      </Fieldset>
      <Text size={3} aria-live='polite' style={{ fontVariantNumeric: 'tabular-nums' }}>
        {seats} seats × ${selected.perMonth.toFixed(2)} = <strong>${total.toFixed(2)}</strong> per month
      </Text>
    </div>
  );
}

Форма/интеграция

Форма/интеграция: обязательная группа и FormData

required на радиокнопках делает обязательной всю группу, и браузер не даёт отправить форму, пока ничего не выбрано. FormData.get('size') возвращает отмеченный value; сброс снимает выбор.

form-data-submission.tsx · 41 строка
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('');

  const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
    event.preventDefault();
    const data = new FormData(event.currentTarget);
    setResult(`size=${data.get('size')}`);
  };

  return (
    <form onSubmit={handleSubmit} onReset={() => setResult('')} style={{ display: 'grid', gap: 'var(--spacing-12)', width: '100%' }}>
      <Fieldset>
        <FieldsetLegend style={{ marginBlockEnd: 'var(--spacing-5)' }}>Server size</FieldsetLegend>
        <div style={{ display: 'grid', gap: 'var(--spacing-6)' }}>
          {sizes.map((size) => (
            <label key={size.value} style={{ display: 'flex', alignItems: 'center', gap: 'var(--spacing-4)' }}>
              <Radio name='size' value={size.value} required />
              {size.label}
            </label>
          ))}
        </div>
      </Fieldset>
      <div style={{ display: 'grid', gap: 'var(--spacing-6)' }}>
        <div style={{ display: 'flex', gap: 'var(--spacing-4)' }}>
          <Button type='submit'>Create server</Button>
          <Button type='reset' variant='secondary' appearance='ghost'>Reset</Button>
        </div>
        <Text size={2} aria-live='polite' style={{ color: 'var(--label-secondary)' }}>{result}</Text>
      </div>
    </form>
  );
}

Раскладка/оверлей

Раскладка/оверлей: выбор внутри диалога

Радиокнопки находятся в form method='dialog': сохранение читает FormData, обновляет значение снаружи и закрывает диалог. При повторном открытии выбор начинается с сохранённого значения, потому что содержимое монтируется заново.

radio-choice-in-overlays.tsx · 71 строка
import { CSSProperties, FormEvent, useState } from 'react';
import {
  Button,
  Dialog,
  DialogClose,
  DialogContent,
  DialogTrigger,
  Fieldset,
  FieldsetLegend,
  Heading,
  Icon,
  IconButton,
  Radio,
  Text,
} from '@vega-ui/react';
import { XIcon } from '@vega-ui/icons';

const environments = [
  { value: 'preview', label: 'Preview' },
  { value: 'staging', label: 'Staging' },
  { value: 'production', label: 'Production' },
];

export default function RadioChoiceInOverlays() {
  const [environment, setEnvironment] = useState('staging');

  const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
    const data = new FormData(event.currentTarget);
    setEnvironment(String(data.get('environment')));
  };

  return (
    <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 'var(--spacing-6)', width: '100%' }}>
      <Text size={3}>Deploy target: <strong>{environment}</strong></Text>
      <Dialog>
        <DialogTrigger asChild>
          <Button variant='secondary' appearance='ghost'>Change</Button>
        </DialogTrigger>
        <DialogContent aria-labelledby='deploy-target-title'>
          <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 'var(--spacing-8)' }}>
            <Heading id='deploy-target-title' as='h2' size={4} fontWeight={600}>Deploy target</Heading>
            <DialogClose asChild>
              <IconButton
                aria-label='Close'
                variant='secondary'
                appearance='ghost'
                style={{ flexShrink: 0, '--icon-button-size': '24px', '--icon-button-br-ratio': 0.5 } as CSSProperties}
              >
                <Icon size='xs'><XIcon strokeWidth={2.5} /></Icon>
              </IconButton>
            </DialogClose>
          </div>
          <form method='dialog' onSubmit={handleSubmit} style={{ display: 'grid', gap: 'var(--spacing-12)', marginBlockStart: 'var(--spacing-8)' }}>
            <Fieldset>
              <FieldsetLegend style={{ marginBlockEnd: 'var(--spacing-5)' }}>Environment</FieldsetLegend>
              <div style={{ display: 'grid', gap: 'var(--spacing-6)' }}>
                {environments.map((item) => (
                  <label key={item.value} style={{ display: 'flex', alignItems: 'center', gap: 'var(--spacing-4)' }}>
                    <Radio name='environment' value={item.value} defaultChecked={item.value === environment} />
                    {item.label}
                  </label>
                ))}
              </div>
            </Fieldset>
            <Button type='submit'>Save</Button>
          </form>
        </DialogContent>
      </Dialog>
    </div>
  );
}

Ошибка

Ошибка: обязательная группа без выбора

Событие invalid обязательных радиокнопок заменяет подсказку браузера: ошибка связывается с Fieldset через aria-describedby, радиокнопки получают aria-invalid, а фокус переходит на первый вариант. Выбор любого варианта убирает ошибку.

missing-required-radio-selection.tsx · 55 строк
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);
  const [done, setDone] = useState(false);
  const firstRef = useRef<HTMLInputElement>(null);

  const handleInvalid = (event: FormEvent<HTMLInputElement>) => {
    event.preventDefault();
    firstRef.current?.focus();
    setInvalid(true);
  };

  const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
    event.preventDefault();
    setDone(true);
  };

  return (
    <form onSubmit={handleSubmit} style={{ display: 'grid', gap: 'var(--spacing-12)', justifyItems: 'start', width: '100%' }}>
      <Fieldset style={{ width: '100%' }} aria-describedby={invalid ? 'cancel-reason-error' : undefined}>
        <FieldsetLegend style={{ marginBlockEnd: 'var(--spacing-5)' }}>Why are you cancelling?</FieldsetLegend>
        <div style={{ display: 'grid', gap: 'var(--spacing-6)' }}>
          {reasons.map((reason, index) => (
            <label key={reason.value} style={{ display: 'flex', alignItems: 'center', gap: 'var(--spacing-4)' }}>
              <Radio
                ref={index === 0 ? firstRef : undefined}
                name='reason'
                value={reason.value}
                required
                aria-invalid={invalid}
                onInvalid={handleInvalid}
                onChange={() => setInvalid(false)}
              />
              {reason.label}
            </label>
          ))}
        </div>
        {invalid && <HelperText id='cancel-reason-error' error>Choose a reason to continue.</HelperText>}
      </Fieldset>
      <div style={{ display: 'grid', gap: 'var(--spacing-6)', justifyItems: 'start' }}>
        <Button type='submit'>Cancel subscription</Button>
        {done && <HelperText aria-live='polite'>Your subscription ends on the last day of this billing period.</HelperText>}
      </div>
    </form>
  );
}

Отключённый

Отключённый: отключённый вариант и отключённая группа

Недоступный вариант отключён и объясняет причину через aria-describedby. Fieldset disabled нативно отключает все радиокнопки внутри. Текст подписи приглушает сам пример, потому что Radio стилизует только поле. Отключённые радиокнопки не отправляются.

disabled-option.tsx · 42 строки
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>
          <label style={{ display: 'flex', alignItems: 'center', gap: 'var(--spacing-4)' }}>
            <Radio name='region' value='us' />
            United States (Virginia)
          </label>
          <div style={{ display: 'grid', gap: 'var(--spacing-2)' }}>
            <label style={{ display: 'flex', alignItems: 'center', gap: 'var(--spacing-4)', color: 'var(--label-tertiary)' }}>
              <Radio name='region' value='au' disabled aria-describedby='region-au-note' />
              Australia (Sydney)
            </label>
            <HelperText id='region-au-note' style={{ paddingInlineStart: 'var(--spacing-12)' }}>Opens in Q3. Join the waitlist in Settings → Regions.</HelperText>
          </div>
        </div>
      </Fieldset>
      <Fieldset disabled aria-describedby='backups-note'>
        <FieldsetLegend style={{ marginBlockEnd: 'var(--spacing-2)' }}>Backup frequency</FieldsetLegend>
        <HelperText id='backups-note'>Managed by your organization policy.</HelperText>
        <div style={{ display: 'grid', gap: 'var(--spacing-6)' }}>
          <label style={{ display: 'flex', alignItems: 'center', gap: 'var(--spacing-4)', color: 'var(--label-tertiary)' }}>
            <Radio name='backups' value='daily' defaultChecked />
            Daily
          </label>
          <label style={{ display: 'flex', alignItems: 'center', gap: 'var(--spacing-4)', color: 'var(--label-tertiary)' }}>
            <Radio name='backups' value='hourly' />
            Hourly
          </label>
        </div>
      </Fieldset>
    </div>
  );
}

Пограничный случай

Пограничный случай: варианты с длинными описаниями

Когда у каждого варианта есть описание, подпись связывается через htmlFor/id, описание — через aria-describedby, а радиокнопка выравнивается по первой строке с flex-start и flex-shrink: 0, чтобы длинный текст её не сжимал.

options-with-descriptions.tsx · 47 строк
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.',
  },
  {
    value: 'invited',
    label: 'Invited people only',
    description: 'Every document must be shared with each person by email. Existing links stop working.',
  },
];

export default function OptionsWithDescriptions() {
  return (
    <Fieldset style={{ width: '100%' }}>
      <FieldsetLegend style={{ marginBlockEnd: 'var(--spacing-5)' }}>Link sharing</FieldsetLegend>
      <div style={{ display: 'grid', gap: 'var(--spacing-8)' }}>
        {policies.map((policy) => (
          <div key={policy.value} style={{ display: 'flex', alignItems: 'flex-start', gap: 'var(--spacing-4)' }}>
            <Radio
              id={`sharing-${policy.value}`}
              name='sharing'
              value={policy.value}
              defaultChecked={policy.value === 'organization'}
              aria-describedby={`sharing-${policy.value}-description`}
              style={{ flexShrink: 0, marginBlockStart: 'var(--spacing-1)' }}
            />
            <div style={{ display: 'grid', gap: 'var(--spacing-1)' }}>
              <label htmlFor={`sharing-${policy.value}`}>{policy.label}</label>
              <Text id={`sharing-${policy.value}-description`} size={2} style={{ color: 'var(--label-secondary)' }}>
                {policy.description}
              </Text>
            </div>
          </div>
        ))}
      </div>
    </Fieldset>
  );
}

Пограничный случай: повторяющимся группам нужны уникальные имена

Каждая строка таблицы — отдельная группа, поэтому у каждой строки свой name, собранный из id строки; общее имя превратило бы всю таблицу в одну группу. Радиокнопки без видимого текста получают aria-label с названием и строки, и варианта.

repeated-groups-unique-names.tsx · 48 строк
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' });

  const assign = (memberId: string, role: string) => {
    setAssigned({ ...assigned, [memberId]: role });
  };

  return (
    <Table fullWidth>
      <TableHead>
        <TableRow>
          <TableHeading>Member</TableHeading>
          {roles.map((role) => (
            <TableHeading key={role} dataAlign='center' style={{ textTransform: 'capitalize' }}>{role}</TableHeading>
          ))}
        </TableRow>
      </TableHead>
      <TableBody>
        {members.map((member) => (
          <TableRow key={member.id}>
            <TableData>{member.name}</TableData>
            {roles.map((role) => (
              <TableData key={role} dataAlign='center'>
                <Radio
                  name={`role-${member.id}`}
                  value={role}
                  aria-label={`${member.name}: ${role}`}
                  checked={assigned[member.id] === role}
                  onChange={() => assign(member.id, role)}
                />
              </TableData>
            ))}
          </TableRow>
        ))}
      </TableBody>
    </Table>
  );
}

Анатомия

Обзор

Radio — один нативный input, а не составной компонент из нескольких частей. Реальный контракт композиции — в том, как несколько радиокнопок группируются через общий name и видимые подписи.

Обязательные части

Radio

Обязательная часть. Рендерит нативный input[type='radio'] и отвечает за отмеченное и отключённое состояния, размер и вариант.

Необязательные части

Radio не предоставляет дополнительных публичных дочерних частей.

Порядок композиции

  1. видимая обёртка-подпись
  2. Radio
  3. текст подписи

Допустимые варианты композиции

Два корректных способа дать радиокнопке имя: обернуть её в <label> вместе с текстом или задать ей id и направить на неё отдельный Label через htmlFor. Обе радиокнопки используют один name, поэтому образуют одну группу.

valid-composition-patterns.tsx · 16 строк
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>
      </div>
    </div>
  );
}

Недопустимые варианты композиции

Радиокнопки без общего name

Это ломает поведение взаимоисключающей группы.

Radio как самостоятельный булев переключатель

Если выбор независимый, а не взаимоисключающий, используйте Checkbox или Switch.

Текст подписи отделён от контрола

Из-за этого радиокнопку сложнее понять и сложнее нажать.