import { Option } from '@vega-ui/react';
<div role='listbox' aria-label='Issue status'>
<Option>Backlog</Option>
<Option selected>In progress</Option>
<Option>Done</Option>
</div>- Поддержка браузеров
- Широко доступно
- Chrome 111
- Edge 111
- Firefox 113
- Safari 15.4
- Safari на iOS 15.4
- Размер в бандле
- Протестирован в
- 14 тестов · 6 нояб.
- История версий
- Добавлен в v1.0.0, последнее изменение в v3.0.0
- Oklab and OkLCh — приходит из дизайн-токенов
- Logical properties
- :where()
Кратко
Option — презентационный примитив выбираемой строки, который переиспользуют спископодобные контролы. Это div с role='option' и состояниями выбора, активности, размера и отключения, но логикой выбора он сам не управляет. Для работающего списка используйте Listbox или Select.
Импорт
import { Option, type OptionProps, type OptionSize } from '@vega-ui/react';Экспортируемые типы
OptionPropsOptionSize
Базовое использование
import { Option } from '@vega-ui/react';
export default function BasicUsage() {
return (
<div role='listbox' aria-label='Issue status' style={{ display: 'flex', flexDirection: 'column', gap: 'var(--spacing-3)', width: '100%', maxWidth: 260, padding: 'var(--spacing-4)', borderRadius: 'var(--spacing-8)', background: 'var(--surface-secondary)', border: '1px solid var(--separator-opaque)', boxSizing: 'border-box' }}>
<Option>Backlog</Option>
<Option selected>In progress</Option>
<Option>In review</Option>
<Option>Done</Option>
</div>
);
}Варианты
- Размер:
xs,sm,md,lg,xl - Состояние: выбранный, активный, отключённый
- Содержимое: простая метка или более богатая строка с иконкой и текстом
Пограничные случаи
Option— UI-примитив, а не полноценная система выбора или меню.- Отключённый
Optionне вызываетonClick. - Родительские контролы по-прежнему должны согласованно управлять активным вариантом, выбором и навигацией с клавиатуры.
Частые ошибки
- Использовать
Optionотдельно и ждать от него управления состоянием выбора. - Сочетать размер
Optionс несовпадающим размером родительского поля. - Забывать согласовать
selectedи видимое активное состояние в родительской системе.
Примеры
Базовый
Базовый: строки внутри именованного списка
Option рендерит строку с role='option', поэтому она всегда находится внутри элемента с role='listbox' и доступным именем. selected только отображает и объявляет состояние, но не управляет им.
import { Option } from '@vega-ui/react';
export default function BasicUsage() {
return (
<div role='listbox' aria-label='Issue status' style={{ display: 'flex', flexDirection: 'column', gap: 'var(--spacing-3)', width: '100%', maxWidth: 260, padding: 'var(--spacing-4)', borderRadius: 'var(--spacing-8)', background: 'var(--surface-secondary)', border: '1px solid var(--separator-opaque)', boxSizing: 'border-box' }}>
<Option>Backlog</Option>
<Option selected>In progress</Option>
<Option>In review</Option>
<Option>Done</Option>
</div>
);
}Контролируемый/с состоянием
Контролируемый/с состоянием: выбранное состояние от родителя
Собственный список владеет обоими состояниями: selected меняется по клику или Enter, active — по стрелкам. Фокус остаётся на списке, который указывает на активную строку через aria-activedescendant, поэтому каждой строке нужен id.
import { Option, Text } from '@vega-ui/react';
import { KeyboardEvent, useId, useState } from 'react';
const statuses = [
{ value: 'backlog', label: 'Backlog' },
{ value: 'progress', label: 'In progress' },
{ value: 'review', label: 'In review' },
{ value: 'done', label: 'Done' },
];
export default function SelectedStateFromParent() {
const id = useId();
const [selected, setSelected] = useState('review');
const [active, setActive] = useState<string | null>(null);
const current = statuses.find((status) => status.value === selected);
const moveActive = (step: number) => {
const index = statuses.findIndex((status) => status.value === active);
const next = Math.min(Math.max(index + step, 0), statuses.length - 1);
setActive(statuses[next].value);
};
const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
if (event.key === 'ArrowDown') {
event.preventDefault();
moveActive(1);
return;
}
if (event.key === 'ArrowUp') {
event.preventDefault();
moveActive(-1);
return;
}
if (event.key !== 'Enter' && event.key !== ' ') return;
if (active === null) return;
event.preventDefault();
setSelected(active);
};
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--spacing-4)', width: '100%', maxWidth: 260 }}>
<div
role='listbox'
tabIndex={0}
aria-label='Issue status'
aria-activedescendant={active === null ? undefined : `${id}-${active}`}
onKeyDown={onKeyDown}
onFocus={() => setActive(selected)}
onBlur={() => setActive(null)}
style={{ display: 'flex', flexDirection: 'column', gap: 'var(--spacing-3)', padding: 'var(--spacing-4)', borderRadius: 'var(--spacing-8)', background: 'var(--surface-secondary)', border: '1px solid var(--separator-opaque)', boxSizing: 'border-box' }}
>
{statuses.map((status) => (
<Option
key={status.value}
id={`${id}-${status.value}`}
selected={status.value === selected}
active={status.value === active}
onClick={() => setSelected(status.value)}
>
{status.label}
</Option>
))}
</div>
<Text size={2} style={{ color: 'var(--label-secondary)' }}>Status: {current?.label}</Text>
</div>
);
}Форма/интеграция
Форма/интеграция: подсказки для текстового поля
Текстовое поле служит комбобоксом и держит фокус: оно указывает на активную подсказку через aria-activedescendant, а Enter или клик копируют имя в поле, которое форма отправляет как assignee. onMouseDown не даёт строке забрать фокус у поля.
import { Button, Label, Option, Text, TextField, TextFieldInput } from '@vega-ui/react';
import { FormEvent, KeyboardEvent, useId, useState } from 'react';
const people = ['Amara Okafor', 'Boris Petrov', 'Chloé Lefèvre', 'Dmitri Volkov', 'Elena Rossi', 'Farah Haddad'];
export default function OptionInCustomSelectorList() {
const id = useId();
const listId = `${id}-list`;
const [query, setQuery] = useState('');
const [active, setActive] = useState(0);
const [submitted, setSubmitted] = useState('');
const matches = people.filter((person) => person.toLowerCase().includes(query.trim().toLowerCase()));
const open = query.trim() !== '' && !people.includes(query);
const choose = (person: string) => {
setQuery(person);
setActive(0);
};
const onKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
if (!open || matches.length === 0) return;
if (event.key === 'ArrowDown') {
event.preventDefault();
setActive(Math.min(active + 1, matches.length - 1));
return;
}
if (event.key === 'ArrowUp') {
event.preventDefault();
setActive(Math.max(active - 1, 0));
return;
}
if (event.key !== 'Enter') return;
event.preventDefault();
choose(matches[active]);
};
const onSubmit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
setSubmitted(String(new FormData(event.currentTarget).get('assignee')));
};
return (
<form onSubmit={onSubmit} style={{ display: 'flex', flexDirection: 'column', gap: 'var(--spacing-3)', width: '100%', maxWidth: 300 }}>
<Label htmlFor={id}>Assignee</Label>
<TextField>
<TextFieldInput
id={id}
name='assignee'
role='combobox'
autoComplete='off'
aria-expanded={open}
aria-controls={listId}
aria-activedescendant={open && matches.length > 0 ? `${listId}-${active}` : undefined}
placeholder='Start typing a name'
value={query}
onChange={(event) => {
setQuery(event.currentTarget.value);
setActive(0);
}}
onKeyDown={onKeyDown}
/>
</TextField>
<div
role='listbox'
id={listId}
aria-label='Matching people'
hidden={!open}
style={{ display: open ? 'flex' : 'none', flexDirection: 'column', gap: 'var(--spacing-3)', padding: 'var(--spacing-4)', borderRadius: 'var(--spacing-8)', background: 'var(--surface-secondary)', border: '1px solid var(--separator-opaque)', boxSizing: 'border-box' }}
>
{matches.map((person, index) => (
<Option
key={person}
id={`${listId}-${index}`}
size='sm'
active={index === active}
onMouseDown={(event) => event.preventDefault()}
onClick={() => choose(person)}
>
{person}
</Option>
))}
{matches.length === 0 && (
<Text size={2} style={{ padding: 'var(--spacing-4)', color: 'var(--label-secondary)' }}>Nobody matches “{query}”.</Text>
)}
</div>
<Button type='submit' style={{ alignSelf: 'flex-end', marginBlockStart: 'var(--spacing-9)' }}>Assign</Button>
{submitted && (
<Text size={2} role='status' style={{ color: 'var(--label-secondary)', marginBlockStart: 'var(--spacing-4)' }}>
Assigned to {submitted}.
</Text>
)}
</form>
);
}Раскладка/оверлей
Раскладка/оверлей: насыщенное содержимое варианта
Строки с иконкой и подсказкой внутри приподнятой панели. Строка остаётся одной поверхностью выбора; двухстрочному содержимому нужен height: 'auto', потому что высота строки зафиксирована размером.
import { Icon, Option, Text } from '@vega-ui/react';
import { CopyIcon, LinkIcon, MailIcon, Share2Icon } from '@vega-ui/icons';
const actions = [
{ value: 'link', label: 'Copy link', hint: 'Anyone with the link can view', icon: LinkIcon },
{ value: 'email', label: 'Send by email', hint: 'Up to 20 recipients', icon: MailIcon },
{ value: 'duplicate', label: 'Duplicate', hint: 'Creates a private copy', icon: CopyIcon },
{ value: 'export', label: 'Export to Drive', hint: 'PDF or DOCX', icon: Share2Icon },
];
export default function RichOptionContent() {
return (
<div
role='listbox'
aria-label='Share options'
style={{
display: 'flex',
flexDirection: 'column',
gap: 'var(--spacing-3)',
padding: 'var(--spacing-4)',
width: '100%',
maxWidth: 300,
boxSizing: 'border-box',
borderRadius: 'var(--spacing-8)',
background: 'var(--surface-secondary)',
border: '1px solid var(--separator-opaque)',
boxShadow: 'var(--surface-shadow)',
}}
>
{actions.map((action) => (
<Option
key={action.value}
selected={action.value === 'link'}
style={{ height: 'auto', paddingBlock: 'var(--spacing-5)', gap: 'var(--spacing-8)' }}
>
<Icon size='sm'>
<action.icon />
</Icon>
<span style={{ display: 'flex', flexDirection: 'column' }}>
<Text size={3}>{action.label}</Text>
<Text size={2} style={{ opacity: 0.75 }}>{action.hint}</Text>
</span>
</Option>
))}
</div>
);
}Отключённый
Отключённый: недоступные варианты с причиной
Отключённые строки остаются видимыми и игнорируют клики. Причина показана в строке и связана через aria-describedby, поэтому скринридер объявляет, почему тариф нельзя выбрать.
import { Option } from '@vega-ui/react';
import { useId } from 'react';
const plans = [
{ value: 'starter', label: 'Starter', reason: '' },
{ value: 'team', label: 'Team', reason: '' },
{ value: 'legacy', label: 'Legacy Pro', reason: 'No longer available for new workspaces' },
{ value: 'enterprise', label: 'Enterprise', reason: 'Requires a sales contract' },
];
export default function UnavailablePlanHint() {
const id = useId();
return (
<div role='listbox' aria-label='Plan' style={{ display: 'flex', flexDirection: 'column', gap: 'var(--spacing-3)', width: '100%', maxWidth: 320, padding: 'var(--spacing-4)', borderRadius: 'var(--spacing-8)', background: 'var(--surface-secondary)', border: '1px solid var(--separator-opaque)', boxSizing: 'border-box' }}>
{plans.map((plan) => (
<Option
key={plan.value}
selected={plan.value === 'team'}
disabled={plan.reason !== ''}
aria-describedby={plan.reason === '' ? undefined : `${id}-${plan.value}`}
style={plan.reason === '' ? undefined : { height: 'auto', paddingBlock: 'var(--spacing-5)' }}
>
<span style={{ display: 'flex', flexDirection: 'column' }}>
{plan.label}
{plan.reason !== '' && (
<span id={`${id}-${plan.value}`} style={{ fontSize: 'var(--font-size-2)' }}>
{plan.reason}
</span>
)}
</span>
</Option>
))}
</div>
);
}Пограничный случай
Пограничный случай: активная, но не выбранная строка
active и selected независимы: активная строка — там, где клавиатура, выбранная — это значение. Здесь значение — «Newest first», а список указывает на «Oldest first».
import { Option } from '@vega-ui/react';
import { useId } from 'react';
export default function ActiveButNotSelectedRow() {
const id = useId();
return (
<div
role='listbox'
tabIndex={0}
aria-label='Sort by'
aria-activedescendant={`${id}-oldest`}
style={{ display: 'flex', flexDirection: 'column', gap: 'var(--spacing-3)', width: '100%', maxWidth: 260, padding: 'var(--spacing-4)', borderRadius: 'var(--spacing-8)', background: 'var(--surface-secondary)', border: '1px solid var(--separator-opaque)', boxSizing: 'border-box' }}
>
<Option id={`${id}-newest`} selected>Newest first</Option>
<Option id={`${id}-oldest`} active>Oldest first</Option>
<Option id={`${id}-popular`}>Most popular</Option>
<Option id={`${id}-price`} disabled>Price, low to high</Option>
</div>
);
}Пограничный случай: длинные подписи в узком списке
У строк фиксированная высота, поэтому длинный текст нужно обрезать внутри строки. Полный путь остаётся доступен в title.
import { Option } from '@vega-ui/react';
const folders = [
'Inbox',
'Projects / 2026 / Northwind logistics / Contracts and signed amendments',
'Receipts',
'Archive / Customer support / Escalations that were resolved after the SLA deadline',
];
export default function LongLabel() {
return (
<div role='listbox' aria-label='Move to folder' style={{ display: 'flex', flexDirection: 'column', gap: 'var(--spacing-3)', width: '100%', maxWidth: 260, padding: 'var(--spacing-4)', borderRadius: 'var(--spacing-8)', background: 'var(--surface-secondary)', border: '1px solid var(--separator-opaque)', boxSizing: 'border-box' }}>
{folders.map((folder) => (
<Option key={folder} title={folder} selected={folder === 'Receipts'}>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{folder}</span>
</Option>
))}
</div>
);
}Анатомия
Обзор
Option — примитив из одной части, который рендерит div с role='option'.
Обязательные части
Option
Обязательная. Владеет всей поверхностью строки варианта, размерами и презентационными состояниями.
Необязательные части
Экспортируемых дочерних частей нет.
Порядок композиции
Option- метка и необязательное богатое содержимое
Корректные паттерны композиции
import { Icon, Option, Text } from '@vega-ui/react';
import { CopyIcon, LinkIcon, MailIcon, Share2Icon } from '@vega-ui/icons';
const actions = [
{ value: 'link', label: 'Copy link', hint: 'Anyone with the link can view', icon: LinkIcon },
{ value: 'email', label: 'Send by email', hint: 'Up to 20 recipients', icon: MailIcon },
{ value: 'duplicate', label: 'Duplicate', hint: 'Creates a private copy', icon: CopyIcon },
{ value: 'export', label: 'Export to Drive', hint: 'PDF or DOCX', icon: Share2Icon },
];
export default function RichOptionContent() {
return (
<div
role='listbox'
aria-label='Share options'
style={{
display: 'flex',
flexDirection: 'column',
gap: 'var(--spacing-3)',
padding: 'var(--spacing-4)',
width: '100%',
maxWidth: 300,
boxSizing: 'border-box',
borderRadius: 'var(--spacing-8)',
background: 'var(--surface-secondary)',
border: '1px solid var(--separator-opaque)',
boxShadow: 'var(--surface-shadow)',
}}
>
{actions.map((action) => (
<Option
key={action.value}
selected={action.value === 'link'}
style={{ height: 'auto', paddingBlock: 'var(--spacing-5)', gap: 'var(--spacing-8)' }}
>
<Icon size='sm'>
<action.icon />
</Icon>
<span style={{ display: 'flex', flexDirection: 'column' }}>
<Text size={3}>{action.label}</Text>
<Text size={2} style={{ opacity: 0.75 }}>{action.hint}</Text>
</span>
</Option>
))}
</div>
);
}Каждый Option находится внутри элемента с role='listbox' и доступным именем. Содержимое строки может включать иконку и две строки текста, но строка остаётся одной поверхностью выбора; строкам больше чем в одну линию нужен height: 'auto'.
Некорректные паттерны композиции
Option как самостоятельный селектор без родительского состояния
Строка рендерится, но семантика выбора неполная.
Сложные вложенные интерактивные элементы внутри одного варианта
Строка варианта должна оставаться одной цельной поверхностью выбора.