import { Spinner } from '@vega-ui/react';
<Spinner size={6} aria-label='Loading' />- Поддержка браузеров
- Широко доступно
- Chrome 111
- Edge 111
- Firefox 113
- Safari 15.4
- Safari на iOS 15.4
- Размер в бандле
- Протестирован в
- 11 тестов · 6 нояб.
- История версий
- Добавлен в v1.0.0, последнее изменение в v3.0.0
- Oklab and OkLCh — приходит из дизайн-токенов
- :where()
Кратко
Spinner — индикатор загрузки для состояний ожидания и асинхронных действий. Его размер задаётся числом, а цвет — вариантами primary или secondary; он подходит, когда интерфейсу нужна лишь компактная обратная связь о процессе с неопределённой длительностью.
Импорты
import {
Spinner,
type SpinnerProps,
type SpinnerSize,
type SpinnerVariant,
} from '@vega-ui/react';Экспортируемые типы
SpinnerPropsSpinnerSizeSpinnerVariant
Базовое использование
import { Spinner } from '@vega-ui/react';
export default function BasicUsage() {
return <Spinner size={3} aria-label='Loading messages' />;
}Варианты
- Размер: от
1до11 - Вариант:
primaryилиsecondary - Размещение: в строке, внутри контролов или по центру поверхностей
Пограничные случаи
- Спиннер без контекста может быть непонятен.
- Слишком крупные спиннеры внутри компактных контролов нарушают баланс.
- Индикаторы загрузки по возможности должны сохранять стабильность раскладки.
- Если точная степень завершения известна, полоса
Progressобычно передаёт состояние лучше спиннера.
Частые ошибки
- Показывать одинокий спиннер без поясняющего контекста.
- Заменять спиннером слишком много содержимого, когда скелетон или сохранённая раскладка были бы понятнее.
- Использовать один и тот же размер для загрузки в кнопке и загрузки страницы.
Примеры
Базовый
Базовый: строка статуса с видимой подписью
Спиннер берёт доступное имя из видимого текста рядом через aria-labelledby, поэтому текст на экране и зачитываемое имя не расходятся.
import { Spinner, Text } from '@vega-ui/react';
import { useId } from 'react';
export default function InlineStatusRow() {
const labelId = useId();
return (
<div style={{ display: 'flex', gap: 'var(--spacing-4)', alignItems: 'center' }}>
<Spinner size={2} aria-labelledby={labelId} />
<Text id={labelId} size={2}>Syncing 128 contacts from Google</Text>
</div>
);
}Контролируемый/с состоянием
Контролируемый/с состоянием: загрузка раздела по запросу
Флаг loading в состоянии решает, что показывает раздел — спиннер или загруженный список. Пока идёт загрузка, раздел помечен aria-busy и сохраняет минимальную высоту, так что кнопка и содержимое ниже не прыгают.
import { Button, Spinner, Text } from '@vega-ui/react';
import { useEffect, useState } from 'react';
const activity = [
'Maya Chen merged “Fix invoice rounding”',
'Deploy #4182 finished in 3m 12s',
'Omar Haddad commented on “Pricing page copy”',
];
export default function ConditionalAsyncState() {
const [loading, setLoading] = useState(false);
const [items, setItems] = useState<string[]>([]);
useEffect(() => {
if (!loading) return;
const timer = setTimeout(() => {
setItems(activity);
setLoading(false);
}, 1500);
return () => clearTimeout(timer);
}, [loading]);
const load = () => {
setItems([]);
setLoading(true);
};
return (
<div style={{ display: 'grid', gap: 'var(--spacing-8)', width: '100%' }}>
<Button size='sm' variant='secondary' appearance='ghost' onClick={load} disabled={loading} style={{ justifySelf: 'start' }}>
{items.length > 0 ? 'Refresh activity' : 'Load activity'}
</Button>
<section
aria-label='Recent activity'
aria-busy={loading}
style={{
display: 'grid',
alignContent: 'center',
minHeight: 132,
padding: 'var(--spacing-8)',
border: '1px solid var(--separator-opaque)',
borderRadius: 'var(--radius-4)',
background: 'var(--surface-primary)',
}}
>
{!loading && items.length === 0 && (
<Text size={2} style={{ color: 'var(--label-tertiary)', textAlign: 'center' }}>No activity loaded yet</Text>
)}
{loading && (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 'var(--spacing-4)' }}>
<Spinner size={3} aria-label='Loading activity' />
<Text size={2} style={{ color: 'var(--label-secondary)' }}>Loading…</Text>
</div>
)}
{!loading && items.length > 0 && (
<ul style={{ display: 'grid', gap: 'var(--spacing-5)', margin: 0, paddingInlineStart: 'var(--spacing-8)' }}>
{items.map((item) => <li key={item}><Text size={2}>{item}</Text></li>)}
</ul>
)}
</section>
</div>
);
}Форма/интеграция
Форма/интеграция: кнопка отправки в ожидании
Обработчик отправки читает значение через FormData и переводит форму в ожидание: <fieldset disabled> блокирует поле и кнопку, а кнопка показывает спиннер и «Saving…». Спиннер помечен aria-hidden, потому что текст кнопки уже говорит, что происходит, а --spinner-color: currentColor оставляет его видимым на залитой кнопке.
import { Button, HelperText, Label, Spinner, TextField, TextFieldInput } from '@vega-ui/react';
import { CSSProperties, FormEvent, useId, useState } from 'react';
export default function ButtonPendingState() {
const fieldId = useId();
const [pending, setPending] = useState(false);
const [message, setMessage] = useState('');
const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
const data = new FormData(event.currentTarget);
const workspace = String(data.get('workspace'));
setPending(true);
setMessage('');
setTimeout(() => {
setPending(false);
setMessage(`Workspace renamed to “${workspace}”.`);
}, 1500);
};
return (
<form onSubmit={handleSubmit} style={{ width: '100%' }}>
<fieldset disabled={pending} style={{ display: 'grid', gap: 'var(--spacing-12)', margin: 0, padding: 0, border: 'none' }}>
<div style={{ display: 'grid', gap: 'var(--spacing-3)' }}>
<Label htmlFor={fieldId}>Workspace name</Label>
<TextField>
<TextFieldInput id={fieldId} name='workspace' required defaultValue='Acme Design' />
</TextField>
<HelperText role='status' style={{ minHeight: '1lh' }}>{message}</HelperText>
</div>
<Button type='submit' style={{ justifySelf: 'start' }}>
{pending && <Spinner size={2} aria-hidden='true' style={{ '--spinner-color': 'currentColor' } as CSSProperties} />}
{pending ? 'Saving…' : 'Save'}
</Button>
</fieldset>
</form>
);
}Раскладка/оверлей
Раскладка/оверлей: загрузка содержимого в диалоге
Диалог открывается сразу и подгружает данные уже после открытия. Спиннер стоит по центру области с зарезервированной высотой, поэтому диалог не меняет размер дважды, когда данные приходят.
import {
Button,
Dialog,
DialogClose,
DialogContent,
DialogTrigger,
Heading,
Icon,
IconButton,
Spinner,
Text,
} from '@vega-ui/react';
import { XIcon } from '@vega-ui/icons';
import { CSSProperties, useEffect, useId, useState } from 'react';
export default function DialogContentLoader() {
const titleId = useId();
const [open, setOpen] = useState(false);
const [loaded, setLoaded] = useState(false);
useEffect(() => {
if (!open || loaded) return;
const timer = setTimeout(() => setLoaded(true), 1200);
return () => clearTimeout(timer);
}, [open, loaded]);
const changeOpen = (next: boolean) => {
setOpen(next);
if (!next) setLoaded(false);
};
return (
<Dialog open={open} onChangeOpen={changeOpen}>
<DialogTrigger asChild>
<Button variant='secondary' appearance='ghost'>Order #10482</Button>
</DialogTrigger>
<DialogContent aria-labelledby={titleId}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 'var(--spacing-8)' }}>
<Heading id={titleId} as='h2' size={4} fontWeight={600}>Order #10482</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>
<div aria-busy={!loaded} style={{ display: 'grid', minHeight: 120, marginBlockStart: 'var(--spacing-8)' }}>
{!loaded && (
<Spinner size={6} aria-label='Loading order details' style={{ placeSelf: 'center' }} />
)}
{loaded && (
<div style={{ display: 'grid', gap: 'var(--spacing-3)', alignContent: 'start' }}>
<Text size={3}>2 × Linen shirt, 1 × Canvas tote</Text>
<Text size={2} style={{ color: 'var(--label-secondary)' }}>Shipped on 28 October via DHL Express</Text>
<Text size={3} fontWeight={600}>Total $148.00</Text>
</div>
)}
</div>
</DialogContent>
</Dialog>
);
}Ошибка
Ошибка: неудачный запрос и повтор
Спиннер не должен крутиться после того, как запрос упал. Первая попытка заканчивается ошибкой, которую объявляет role='alert', и кнопкой Retry; повтор снова показывает спиннер с обновлённой подписью и завершается успешно.
import { Button, HelperText, Spinner, Text } from '@vega-ui/react';
import { useEffect, useId, useState } from 'react';
type Status = 'syncing' | 'failed' | 'synced';
export default function RetryRowStillPending() {
const labelId = useId();
const [status, setStatus] = useState<Status>('syncing');
const [attempt, setAttempt] = useState(1);
useEffect(() => {
if (status !== 'syncing') return;
const timer = setTimeout(() => {
setStatus(attempt === 1 ? 'failed' : 'synced');
}, 1500);
return () => clearTimeout(timer);
}, [status, attempt]);
const retry = () => {
setAttempt(attempt + 1);
setStatus('syncing');
};
const restart = () => {
setAttempt(1);
setStatus('syncing');
};
return (
<div style={{ display: 'grid', gap: 'var(--spacing-8)', width: '100%' }}>
{status === 'syncing' && (
<div style={{ display: 'flex', gap: 'var(--spacing-4)', alignItems: 'center' }}>
<Spinner size={2} aria-labelledby={labelId} />
<Text id={labelId} size={2}>{attempt === 1 ? 'Syncing calendar…' : `Retrying (attempt ${attempt})…`}</Text>
</div>
)}
{status === 'failed' && (
<>
<HelperText error role='alert'>Calendar sync failed: the server did not respond in time.</HelperText>
<Button size='sm' variant='secondary' appearance='ghost' onClick={retry} style={{ justifySelf: 'start' }}>
Retry
</Button>
</>
)}
{status === 'synced' && (
<>
<HelperText role='status'>Calendar synced after {attempt} attempts.</HelperText>
<Button size='sm' variant='secondary' appearance='ghost' onClick={restart} style={{ justifySelf: 'start' }}>
Run again
</Button>
</>
)}
</div>
);
}Пограничный случай
Пограничный случай: спиннер с задержкой для быстрых ответов
Спиннер появляется, только если запрос идёт дольше 400 мс. Быстрый поиск завершается без мелькания индикатора, медленный всё равно показывает, что работа идёт.
import { Button, Spinner, Text } from '@vega-ui/react';
import { useEffect, useState } from 'react';
export default function DelayedSpinner() {
const [pending, setPending] = useState(false);
const [showSpinner, setShowSpinner] = useState(false);
const [result, setResult] = useState('');
useEffect(() => {
if (!pending) return;
const timer = setTimeout(() => setShowSpinner(true), 400);
return () => clearTimeout(timer);
}, [pending]);
const search = (latency: number) => {
setPending(true);
setShowSpinner(false);
setResult('');
setTimeout(() => {
setPending(false);
setShowSpinner(false);
setResult(`Found 24 results in ${latency} ms`);
}, latency);
};
return (
<div style={{ display: 'grid', gap: 'var(--spacing-8)', width: '100%' }}>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 'var(--spacing-4)' }}>
<Button size='sm' variant='secondary' appearance='ghost' onClick={() => search(150)} disabled={pending}>
Fast search (150 ms)
</Button>
<Button size='sm' variant='secondary' appearance='ghost' onClick={() => search(2000)} disabled={pending}>
Slow search (2 s)
</Button>
</div>
<div aria-live='polite' style={{ display: 'flex', alignItems: 'center', gap: 'var(--spacing-4)', minHeight: 'var(--spacing-12)' }}>
{showSpinner && <Spinner size={3} aria-label='Searching' />}
{showSpinner && <Text size={2} style={{ color: 'var(--label-secondary)' }}>Searching…</Text>}
{result && <Text size={2} style={{ fontVariantNumeric: 'tabular-nums' }}>{result}</Text>}
</div>
</div>
);
}Анатомия
Обзор
Spinner — примитив из одной части. Корень отвечает за анимацию, сопоставление размеров и цвет варианта.
Обязательные части
Spinner
Обязателен. Рендерит анимированный индикатор загрузки.
Необязательные части
Экспортируемых дочерних частей нет.
Порядок композиции
Spinner
Корректные варианты композиции
import { Spinner, Text } from '@vega-ui/react';
import { useId } from 'react';
export default function InlineStatusRow() {
const labelId = useId();
return (
<div style={{ display: 'flex', gap: 'var(--spacing-4)', alignItems: 'center' }}>
<Spinner size={2} aria-labelledby={labelId} />
<Text id={labelId} size={2}>Syncing 128 contacts from Google</Text>
</div>
);
}Некорректные варианты композиции
Спиннер без окружающего контекста
Пользователи могут не понять, что загружается.
Спиннер как постоянное украшение
Компонент должен сообщать о временном состоянии ожидания.