import {
SegmentedControl,
SegmentedControlIndicator,
SegmentedControlItem,
SegmentedControlItemInput,
} from '@vega-ui/react';
<SegmentedControl size='sm' name='preview-view' defaultValue='week' aria-label='Calendar view'>
{['Day', 'Week', 'Month'].map((label) => (
<SegmentedControlItem key={label} value={label.toLowerCase()}>
<SegmentedControlItemInput />
{label}
</SegmentedControlItem>
))}
<SegmentedControlIndicator />
</SegmentedControl>- Поддержка браузеров
- Широко доступно
- Chrome 111
- Edge 111
- Firefox 121
- Safari 15.4
- Safari на iOS 15.4
- Размер в бандле
- Протестирован в
- 19 тестов · 6 нояб.
- История версий
- Добавлен в v1.0.0, последнее изменение в v3.0.0
- Anchor positioning — в коде есть запасной вариант, в минимум не входит
- :has()
- Oklab and OkLCh — приходит из дизайн-токенов
- clip-path
- Logical properties
- :focus-visible
- :where()
Кратко
SegmentedControl — составной компонент для коротких, всегда видимых наборов взаимоисключающих вариантов с нативными радиоинпутами на уровне элементов и анимированным индикатором выбранного состояния. Это контрол взаимоисключающего выбора, ставящий видимость на первое место, когда варианты должны сравниваться с одного взгляда.
Импорты
import {
SegmentedControl,
SegmentedControlItem,
SegmentedControlIndicator,
SegmentedControlItemInput,
type SegmentedControlProps,
type SegmentedControlSize,
type SegmentedControlVariant,
} from '@vega-ui/react';Экспортируемые типы
SegmentedControlPropsSegmentedControlItemPropsSegmentedControlIndicatorPropsSegmentedControlItemInputPropsSegmentedControlSizeSegmentedControlVariant
Минимальная композиция
import {
SegmentedControl,
SegmentedControlIndicator,
SegmentedControlItem,
SegmentedControlItemInput,
} from '@vega-ui/react';
export default function MinimalComposition() {
return (
<SegmentedControl name='billing-period' defaultValue='monthly' role='radiogroup' aria-label='Billing period'>
<SegmentedControlItem value='monthly'>
<SegmentedControlItemInput />
Monthly
</SegmentedControlItem>
<SegmentedControlItem value='yearly'>
<SegmentedControlItemInput />
Yearly
</SegmentedControlItem>
<SegmentedControlIndicator />
</SegmentedControl>
);
}Обязательные части
SegmentedControl: корень, владеющий выбором и индикаторомSegmentedControlItem: видимый выбираемый сегментSegmentedControlItemInput: нативная радиокнопка в каждом сегменте — фокус, стрелки, выбор и значение в формеSegmentedControlIndicator: поверхность выбранного состояния
Необязательные части
- Нет
Порядок композиции
SegmentedControlSegmentedControlItemSegmentedControlItemInput- видимое содержимое элемента
SegmentedControlIndicator
Варианты
- Размеры:
xs,sm,md,lg,xl - Варианты:
primary,secondary - Состояния: выбранный, невыбранный, отключённый
Пограничные случаи
- Скрытые инпуты важны для нативной семантики формы и группы радиокнопок.
- Длинные или сильно различающиеся подписи могут нарушить визуальный баланс.
- Используйте
Select, когда набор вариантов уже не маленький и не должен быть виден постоянно. - Если каждому варианту нужны заголовок, описание или содержимое в виде карточки,
SegmentedControlобычно слишком плотный для этой задачи.
Частые ошибки
- Использовать сегментированный контрол для больших наборов вариантов.
- Пропускать
SegmentedControlItemInput, который и является настоящей радиокнопкой. - Допускать слишком большой разброс длины подписей и нарушать баланс сегментов.
Примеры
Базовый
Базовый: переключатель вида
Каждый сегмент — это SegmentedControlItem со скрытой радиокнопкой SegmentedControlItemInput, и все они используют name корня. Корень сам не задаёт роль, поэтому передайте role='radiogroup' и aria-label, чтобы дать группе имя.
import {
SegmentedControl,
SegmentedControlIndicator,
SegmentedControlItem,
SegmentedControlItemInput,
} from '@vega-ui/react';
export default function ViewRangeSelector() {
return (
<SegmentedControl name='calendar-view' defaultValue='week' role='radiogroup' aria-label='Calendar view'>
<SegmentedControlItem value='day'>
<SegmentedControlItemInput />
Day
</SegmentedControlItem>
<SegmentedControlItem value='week'>
<SegmentedControlItemInput />
Week
</SegmentedControlItem>
<SegmentedControlItem value='month'>
<SegmentedControlItemInput />
Month
</SegmentedControlItem>
<SegmentedControlItem value='year'>
<SegmentedControlItemInput />
Year
</SegmentedControlItem>
<SegmentedControlIndicator />
</SegmentedControl>
);
}Контролируемый/с состоянием
Контролируемый/с состоянием: диапазон дат, управляющий сводкой
Родитель хранит value и обновляет его из event.currentTarget.value в onChange. Суммы посетителей ниже вычисляются из выбранного диапазона.
import { useState } from 'react';
import {
SegmentedControl,
SegmentedControlIndicator,
SegmentedControlItem,
SegmentedControlItemInput,
Text,
} from '@vega-ui/react';
const dailyVisitors = [
1180, 1240, 990, 1430, 1510, 870, 760, 1320, 1290, 1405, 1388, 1460, 910, 820, 1500,
1530, 1475, 1610, 1580, 960, 880, 1620, 1700, 1655, 1590, 1720, 1010, 940, 1750, 1810,
];
const ranges = [
{ value: '7', label: '7 days' },
{ value: '14', label: '14 days' },
{ value: '30', label: '30 days' },
];
export default function ParentOwnedSelectedSegment() {
const [range, setRange] = useState('7');
const days = dailyVisitors.slice(-Number(range));
const total = days.reduce((sum, visitors) => sum + visitors, 0);
const average = Math.round(total / days.length);
return (
<div style={{ display: 'grid', gap: 'var(--spacing-8)', justifyItems: 'start', width: '100%' }}>
<SegmentedControl
name='traffic-range'
role='radiogroup'
aria-label='Date range'
value={range}
onChange={(event) => setRange(event.currentTarget.value)}
>
{ranges.map((item) => (
<SegmentedControlItem key={item.value} value={item.value}>
<SegmentedControlItemInput />
{item.label}
</SegmentedControlItem>
))}
<SegmentedControlIndicator />
</SegmentedControl>
<Text size={3} aria-live='polite' style={{ fontVariantNumeric: 'tabular-nums' }}>
{total.toLocaleString('en-US')} visitors in the last {range} days · {average.toLocaleString('en-US')} per day
</Text>
</div>
);
}Форма/интеграция
Форма/интеграция: чтение значения через FormData
Поля — нативные радиокнопки с name корня, поэтому FormData.get('export-format') возвращает выбранный value. defaultValue становится defaultChecked, поэтому сброс формы возвращает его.
import { FormEvent, useState } from 'react';
import {
Button,
Label,
SegmentedControl,
SegmentedControlIndicator,
SegmentedControlItem,
SegmentedControlItemInput,
Text,
} from '@vega-ui/react';
export default function NativeRadioGroupParticipation() {
const [result, setResult] = useState('');
const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
const data = new FormData(event.currentTarget);
setResult(`format=${data.get('export-format')}`);
};
return (
<form onSubmit={handleSubmit} onReset={() => setResult('')} style={{ display: 'grid', gap: 'var(--spacing-12)', justifyItems: 'start', width: '100%' }}>
<div style={{ display: 'grid', gap: 'var(--spacing-3)' }}>
<Label id='export-format-label'>File format</Label>
<SegmentedControl name='export-format' defaultValue='csv' role='radiogroup' aria-labelledby='export-format-label'>
<SegmentedControlItem value='csv'>
<SegmentedControlItemInput />
CSV
</SegmentedControlItem>
<SegmentedControlItem value='xlsx'>
<SegmentedControlItemInput />
Excel
</SegmentedControlItem>
<SegmentedControlItem value='json'>
<SegmentedControlItemInput />
JSON
</SegmentedControlItem>
<SegmentedControlIndicator />
</SegmentedControl>
</div>
<div style={{ display: 'grid', gap: 'var(--spacing-6)' }}>
<div style={{ display: 'flex', gap: 'var(--spacing-4)' }}>
<Button type='submit'>Export report</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, обновляет значение снаружи и закрывает диалог. Содержимое диалога монтируется при открытии заново, поэтому defaultValue начинается с применённого значения.
import { CSSProperties, FormEvent, useState } from 'react';
import {
Button,
Dialog,
DialogClose,
DialogContent,
DialogTrigger,
Heading,
Icon,
IconButton,
Label,
SegmentedControl,
SegmentedControlIndicator,
SegmentedControlItem,
SegmentedControlItemInput,
Text,
} from '@vega-ui/react';
import { XIcon } from '@vega-ui/icons';
const densities = [
{ value: 'compact', label: 'Compact' },
{ value: 'default', label: 'Default' },
{ value: 'comfortable', label: 'Comfortable' },
];
export default function ExclusiveSelectorInsideOverlays() {
const [density, setDensity] = useState('default');
const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
const data = new FormData(event.currentTarget);
setDensity(String(data.get('density')));
};
return (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 'var(--spacing-6)', width: '100%' }}>
<Text size={3}>Row density: <strong>{density}</strong></Text>
<Dialog>
<DialogTrigger asChild>
<Button variant='secondary' appearance='ghost'>Display settings</Button>
</DialogTrigger>
<DialogContent aria-labelledby='display-settings-title'>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 'var(--spacing-8)' }}>
<Heading id='display-settings-title' as='h2' size={4} fontWeight={600}>Display settings</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)' }}>
<div style={{ display: 'grid', gap: 'var(--spacing-3)' }}>
<Label id='density-label'>Row density</Label>
<SegmentedControl name='density' defaultValue={density} role='radiogroup' aria-labelledby='density-label'>
{densities.map((item) => (
<SegmentedControlItem key={item.value} value={item.value}>
<SegmentedControlItemInput />
{item.label}
</SegmentedControlItem>
))}
<SegmentedControlIndicator />
</SegmentedControl>
</div>
<Button type='submit'>Apply</Button>
</form>
</DialogContent>
</Dialog>
</div>
);
}Ошибка
Ошибка: обязательный выбор без значения по умолчанию
Без defaultValue ничего не выбрано. required на полях блокирует отправку; их событие invalid показывает HelperText, связанный с группой через aria-describedby, помечает поля aria-invalid и переводит фокус на первый сегмент. Выбор размера убирает ошибку.
import { FormEvent, useState } from 'react';
import {
Button,
HelperText,
Label,
SegmentedControl,
SegmentedControlIndicator,
SegmentedControlItem,
SegmentedControlItemInput,
} from '@vega-ui/react';
const sizes = ['XS', 'S', 'M', 'L', 'XL'];
export default function RequiredVisibleChoiceWithoutASelection() {
const [invalid, setInvalid] = useState(false);
const [added, setAdded] = useState('');
const handleInvalid = (event: FormEvent<HTMLInputElement>) => {
event.preventDefault();
if (event.currentTarget.value === sizes[0]) {
event.currentTarget.focus();
}
setInvalid(true);
};
const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
const data = new FormData(event.currentTarget);
setAdded(`Size ${data.get('size')} added to the cart.`);
};
return (
<form onSubmit={handleSubmit} style={{ display: 'grid', gap: 'var(--spacing-12)', justifyItems: 'start', width: '100%' }}>
<div style={{ display: 'grid', gap: 'var(--spacing-3)' }}>
<Label id='size-label'>Size</Label>
<SegmentedControl
name='size'
role='radiogroup'
aria-labelledby='size-label'
aria-describedby={invalid ? 'size-error' : undefined}
onChange={() => setInvalid(false)}
>
{sizes.map((size) => (
<SegmentedControlItem key={size} value={size}>
<SegmentedControlItemInput
required
aria-invalid={invalid}
onInvalid={handleInvalid}
/>
{size}
</SegmentedControlItem>
))}
<SegmentedControlIndicator />
</SegmentedControl>
{invalid && <HelperText id='size-error' error>Choose a size to add the jacket to the cart.</HelperText>}
</div>
<div style={{ display: 'grid', gap: 'var(--spacing-6)', justifyItems: 'start' }}>
<Button type='submit'>Add to cart</Button>
{added && <HelperText aria-live='polite'>{added}</HelperText>}
</div>
</form>
);
}Отключённый
Отключённый: заблокированная группа и один недоступный сегмент
disabled на корне отключает все поля и оставляет выбранный сегмент видимым. Для одного сегмента disabled на SegmentedControlItem меняет только внешний вид; чтобы его нельзя было выбрать, поставьте disabled и на его SegmentedControlItemInput.
import {
HelperText,
Label,
SegmentedControl,
SegmentedControlIndicator,
SegmentedControlItem,
SegmentedControlItemInput,
} from '@vega-ui/react';
export default function SelectedDisabledControl() {
return (
<div style={{ display: 'grid', gap: 'var(--spacing-12)', justifyItems: 'start', width: '100%' }}>
<div style={{ display: 'grid', gap: 'var(--spacing-3)' }}>
<Label id='billing-label'>Billing</Label>
<SegmentedControl
name='billing'
defaultValue='yearly'
disabled
role='radiogroup'
aria-labelledby='billing-label'
aria-describedby='billing-note'
>
<SegmentedControlItem value='monthly'>
<SegmentedControlItemInput />
Monthly
</SegmentedControlItem>
<SegmentedControlItem value='yearly'>
<SegmentedControlItemInput />
Yearly
</SegmentedControlItem>
<SegmentedControlIndicator />
</SegmentedControl>
<HelperText id='billing-note'>Locked until your contract renews on March 1.</HelperText>
</div>
<div style={{ display: 'grid', gap: 'var(--spacing-3)' }}>
<Label id='quality-label'>Video quality</Label>
<SegmentedControl name='quality' defaultValue='1080p' role='radiogroup' aria-labelledby='quality-label'>
<SegmentedControlItem value='720p'>
<SegmentedControlItemInput />
720p
</SegmentedControlItem>
<SegmentedControlItem value='1080p'>
<SegmentedControlItemInput />
1080p
</SegmentedControlItem>
<SegmentedControlItem value='4k' disabled>
<SegmentedControlItemInput disabled aria-describedby='quality-note' />
4K
</SegmentedControlItem>
<SegmentedControlIndicator />
</SegmentedControl>
<HelperText id='quality-note'>4K is available on the Premium plan.</HelperText>
</div>
</div>
);
}Пограничный случай
Пограничный случай: сегменты из одних иконок
У иконок нет текста, поэтому каждый SegmentedControlItemInput получает aria-label, а title на сегменте показывает то же имя при наведении мышью.
import {
Icon,
SegmentedControl,
SegmentedControlIndicator,
SegmentedControlItem,
SegmentedControlItemInput,
} from '@vega-ui/react';
import { MonitorIcon, MoonIcon, SunIcon } from '@vega-ui/icons';
export default function IconOnlyModeSelector() {
return (
<SegmentedControl name='appearance' defaultValue='system' size='sm' role='radiogroup' aria-label='Appearance'>
<SegmentedControlItem value='light' title='Light'>
<SegmentedControlItemInput aria-label='Light' />
<Icon size='sm'><SunIcon /></Icon>
</SegmentedControlItem>
<SegmentedControlItem value='system' title='System'>
<SegmentedControlItemInput aria-label='System' />
<Icon size='sm'><MonitorIcon /></Icon>
</SegmentedControlItem>
<SegmentedControlItem value='dark' title='Dark'>
<SegmentedControlItemInput aria-label='Dark' />
<Icon size='sm'><MoonIcon /></Icon>
</SegmentedControlItem>
<SegmentedControlIndicator />
</SegmentedControl>
);
}Пограничный случай: числовые значения
Значения сегментов могут быть числами, но event.currentTarget.value — всегда строка, поэтому перед сохранением приведите её через Number. Число колонок сетки следует за выбором.
import { useState } from 'react';
import {
Label,
SegmentedControl,
SegmentedControlIndicator,
SegmentedControlItem,
SegmentedControlItemInput,
} from '@vega-ui/react';
const columnOptions = [1, 2, 3, 4];
const photos = ['Harbor', 'Dunes', 'Glacier', 'Canyon', 'Forest', 'Delta', 'Reef', 'Ridge'];
export default function NumericValues() {
const [columns, setColumns] = useState(3);
return (
<div style={{ display: 'grid', gap: 'var(--spacing-8)', width: '100%' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--spacing-6)' }}>
<Label id='columns-label'>Columns</Label>
<SegmentedControl
name='columns'
size='sm'
role='radiogroup'
aria-labelledby='columns-label'
value={columns}
onChange={(event) => setColumns(Number(event.currentTarget.value))}
>
{columnOptions.map((option) => (
<SegmentedControlItem key={option} value={option}>
<SegmentedControlItemInput />
{option}
</SegmentedControlItem>
))}
<SegmentedControlIndicator />
</SegmentedControl>
</div>
<div style={{ display: 'grid', gridTemplateColumns: `repeat(${columns}, 1fr)`, gap: 'var(--spacing-4)' }}>
{photos.map((photo) => (
<div
key={photo}
style={{
display: 'grid',
placeItems: 'center',
height: 64,
borderRadius: 'var(--radius-2)',
background: 'var(--fills-tertiary)',
color: 'var(--label-secondary)',
}}
>
{photo}
</div>
))}
</div>
</div>
);
}Анатомия
Обзор
SegmentedControl — составной компонент для всегда видимых взаимоисключающих вариантов с общим индикатором, нативными радиоинпутами на уровне элементов и индикатором выбранного состояния, привязанным через CSS-якоря.
Обязательные части
SegmentedControl
Обязателен. Отвечает за name группы, контролируемое value или начальное defaultValue, размер, вариант и отключённое состояние.
SegmentedControlItem
Обязателен. Рендерит один выбираемый сегмент и его видимое содержимое.
SegmentedControlIndicator
Обязателен для анимированной поверхности выбранного состояния.
SegmentedControlItemInput
Обязателен в каждом сегменте. Визуально скрытая нативная радиокнопка: от неё берутся фокус, стрелки, выбор, стиль выбранного сегмента и значение в форме.
Необязательные части
Необязательных частей нет. Дайте группе имя через role='radiogroup' и aria-label или aria-labelledby на корне.
Порядок композиции
SegmentedControlSegmentedControlItemSegmentedControlItemInput- видимое содержимое элемента
SegmentedControlIndicator
Корректные варианты композиции
Группа с текстовыми сегментами, получающая имя от видимой подписи через aria-labelledby, и группа из одних иконок, где каждый SegmentedControlItemInput получает собственный aria-label.
import {
Icon,
Label,
SegmentedControl,
SegmentedControlIndicator,
SegmentedControlItem,
SegmentedControlItemInput,
} from '@vega-ui/react';
import { LayoutGridIcon, ListIcon } from '@vega-ui/icons';
export default function ValidCompositionPatterns() {
return (
<div style={{ display: 'grid', gap: 'var(--spacing-8)', justifyItems: 'start' }}>
<div style={{ display: 'grid', gap: 'var(--spacing-3)' }}>
<Label id='orders-filter-label'>Orders</Label>
<SegmentedControl name='orders-filter' defaultValue='open' role='radiogroup' aria-labelledby='orders-filter-label'>
<SegmentedControlItem value='open'>
<SegmentedControlItemInput />
Open
</SegmentedControlItem>
<SegmentedControlItem value='shipped'>
<SegmentedControlItemInput />
Shipped
</SegmentedControlItem>
<SegmentedControlItem value='returned'>
<SegmentedControlItemInput />
Returned
</SegmentedControlItem>
<SegmentedControlIndicator />
</SegmentedControl>
</div>
<SegmentedControl name='orders-layout' defaultValue='list' size='sm' role='radiogroup' aria-label='Layout'>
<SegmentedControlItem value='list'>
<SegmentedControlItemInput aria-label='List' />
<Icon size='sm'><ListIcon /></Icon>
</SegmentedControlItem>
<SegmentedControlItem value='grid'>
<SegmentedControlItemInput aria-label='Grid' />
<Icon size='sm'><LayoutGridIcon /></Icon>
</SegmentedControlItem>
<SegmentedControlIndicator />
</SegmentedControl>
</div>
);
}Некорректные варианты композиции
Элементы без общего корня
Это ломает контекст выбранного состояния и позиционирование индикатора.
Скрытые инпуты пропущены в сценариях с формами
Контрол может выглядеть правильно, но терять нативную семантику группы радиокнопок.
Большие или сильно разнородные наборы вариантов
Сегментированный контрол предназначен для коротких, всегда видимых взаимоисключающих вариантов, а не для длинных меню.