Weekly report
Sent every Monday at 9:00 to 4 people.import { Card, Heading, Text } from '@vega-ui/react';
<Card aria-labelledby='preview-card-title'>
<Heading id='preview-card-title' as='h2' size={4} fontWeight={600}>Weekly report</Heading>
<Text size={2}>Sent every Monday at 9:00 to 4 people.</Text>
</Card>- Поддержка браузеров
- Широко доступно
- Chrome 111
- Edge 111
- Firefox 113
- Safari 15.4
- Safari на iOS 15.4
- Размер в бандле
- Протестирован в
- 14 тестов · 6 нояб.
- История версий
- Добавлен в v1.0.0, последнее изменение в v3.0.0
- Oklab and OkLCh — приходит из дизайн-токенов
- :where()
Кратко
Card — контейнер-поверхность для сгруппированного содержимого: панелей настроек, блоков дашборда и встроенных сводок. Это поверхность для раскладки и группировки, а не примитив обратной связи или статуса.
Импорты
import {
Card,
type CardProps,
type CardSize,
type CardAppearance,
} from '@vega-ui/react';Экспортируемые типы
CardPropsCardSizeCardAppearance
Базовое использование
import { Button, Card, Heading, Text } from '@vega-ui/react';
const features = ['Up to 25 members', '100 GB of storage', 'Priority email support', 'SAML single sign-on'];
export default function BasicUsage() {
return (
<Card aria-labelledby='plan-team-title' style={{ width: '100%', maxWidth: 320, gap: 'var(--spacing-6)' }}>
<Heading id='plan-team-title' as='h3' size={4} fontWeight={600}>Team</Heading>
<Text size={3}>
<Text size={6} fontWeight={500}>$12</Text> per member / month
</Text>
<ul style={{ margin: 0, paddingInlineStart: 'var(--spacing-9)', display: 'grid', gap: 'var(--spacing-2)' }}>
{features.map((feature) => (
<li key={feature}><Text size={2} style={{ color: 'var(--label-secondary)' }}>{feature}</Text></li>
))}
</ul>
<Button fullWidth style={{ marginBlockStart: 'var(--spacing-6)' }}>Start 14-day trial</Button>
</Card>
);
}Варианты
- Размеры:
sm,md,lg - Оформление:
outline,transparent - Композиция:
articleпо умолчанию илиasChild
Пограничные случаи
Card— поверхность раскладки, а не семантическая замена заголовков или разделов.- Прозрачные карточки работают, только когда контраст уже даёт родительская поверхность.
- Внутренние отступы по-прежнему определяет раскладка дочерних элементов.
- Если интерфейс пытается сообщить статус или срочность, а не сгруппировать содержимое, обычно понятнее подходят
AlertилиBadge.
Частые ошибки
- Ожидать, что
Cardавтоматически обеспечит все внутренние отступы раскладки. - Использовать прозрачные карточки на визуально шумных фонах.
- Передавать иерархию только через оформление карточки.
Примеры
Базовый
Базовый: тарифный план
Card рендерит article, доступное имя которого берётся из заголовка через aria-labelledby. Содержимое выстраивается в колонку, а отступы между дочерними элементами задаёт gap на карточке.
import { Button, Card, Heading, Text } from '@vega-ui/react';
const features = ['Up to 25 members', '100 GB of storage', 'Priority email support', 'SAML single sign-on'];
export default function BasicUsage() {
return (
<Card aria-labelledby='plan-team-title' style={{ width: '100%', maxWidth: 320, gap: 'var(--spacing-6)' }}>
<Heading id='plan-team-title' as='h3' size={4} fontWeight={600}>Team</Heading>
<Text size={3}>
<Text size={6} fontWeight={500}>$12</Text> per member / month
</Text>
<ul style={{ margin: 0, paddingInlineStart: 'var(--spacing-9)', display: 'grid', gap: 'var(--spacing-2)' }}>
{features.map((feature) => (
<li key={feature}><Text size={2} style={{ color: 'var(--label-secondary)' }}>{feature}</Text></li>
))}
</ul>
<Button fullWidth style={{ marginBlockStart: 'var(--spacing-6)' }}>Start 14-day trial</Button>
</Card>
);
}Базовый: вся карточка — ссылка
С asChild стили карточки переходят на <a>, и вся поверхность становится одной ссылкой с одним доступным именем. Содержимое такой карточки должно быть неинтерактивным: без кнопок и вложенных ссылок.
import { Card, Heading, Text } from '@vega-ui/react';
import './examples.css';
const destinations = [
{ href: '#billing', title: 'Billing', description: 'Plan, invoices and payment methods.' },
{ href: '#members', title: 'Members', description: '18 members, 3 pending invitations.' },
{ href: '#security', title: 'Security', description: 'Two-factor authentication, sessions and SSO.' },
];
export default function LinkCards() {
return (
<nav aria-label='Workspace settings' style={{ width: '100%' }}>
<ul style={{ listStyle: 'none', margin: 0, padding: 0, display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(min(200px, 100%), 1fr))', gap: 'var(--spacing-6)' }}>
{destinations.map((destination) => (
<li key={destination.href} style={{ display: 'grid' }}>
<Card asChild size='sm' className='linkCard'>
<a href={destination.href} style={{ color: 'inherit', textDecoration: 'none', gap: 'var(--spacing-2)' }}>
<Heading as='h3' size={3} fontWeight={600}>{destination.title}</Heading>
<Text size={2} style={{ color: 'var(--label-secondary)' }}>{destination.description}</Text>
</a>
</Card>
</li>
))}
</ul>
</nav>
);
}Контролируемый/с состоянием
Контролируемый/с состоянием: карточка метрики с обновлением
Значение, время обновления и флаг загрузки хранятся в состоянии. Во время обновления карточка получает aria-busy, у спиннера своё доступное имя, а кнопка отключена.
import { useState } from 'react';
import { Button, Card, Heading, Spinner, Text } from '@vega-ui/react';
const formatter = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' });
export default function MetricsCard() {
const [revenue, setRevenue] = useState(48210.4);
const [updatedAt, setUpdatedAt] = useState('09:00');
const [loading, setLoading] = useState(false);
const refresh = () => {
setLoading(true);
setTimeout(() => {
const now = new Date();
setRevenue((current) => current + Math.round(Math.random() * 90000) / 100);
setUpdatedAt(now.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: false }));
setLoading(false);
}, 900);
};
return (
<Card aria-labelledby='revenue-title' aria-busy={loading} style={{ width: '100%', maxWidth: 300, gap: 'var(--spacing-2)' }}>
<Heading id='revenue-title' as='h3' size={2} fontWeight={500} style={{ color: 'var(--label-secondary)' }}>Revenue this month</Heading>
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--spacing-4)', minHeight: 36 }}>
<Text size={7} fontWeight={500} style={{ fontVariantNumeric: 'tabular-nums' }}>{formatter.format(revenue)}</Text>
{loading && <Spinner size={3} aria-label='Refreshing revenue' />}
</div>
<Text size={2} style={{ color: 'var(--label-tertiary)', fontVariantNumeric: 'tabular-nums' }}>Updated at {updatedAt}</Text>
<Button size='sm' variant='secondary' appearance='ghost' disabled={loading} onClick={refresh} style={{ marginBlockStart: 'var(--spacing-6)' }}>Refresh</Button>
</Card>
);
}Форма/интеграция
Форма/интеграция: карточка и есть форма
asChild рендерит карточку как <form> с доступным именем из заголовка. Значения читаются через FormData при отправке, нативные required и pattern проверяют поля, а сброс очищает сообщение о статусе.
import { FormEvent, useState } from 'react';
import { Button, Card, Heading, Label, Text, TextField, TextFieldInput } from '@vega-ui/react';
export default function SettingsFormCard() {
const [saved, setSaved] = useState('');
const submit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
const data = new FormData(event.currentTarget);
setSaved(`Saved “${data.get('workspace')}” at ${data.get('slug')}.vega.app`);
};
return (
<Card asChild style={{ width: '100%', maxWidth: 420, gap: 'var(--spacing-12)' }}>
<form aria-labelledby='workspace-card-title' onSubmit={submit} onReset={() => setSaved('')}>
<div style={{ display: 'grid', gap: 'var(--spacing-2)' }}>
<Heading id='workspace-card-title' as='h3' size={4} fontWeight={600}>Workspace</Heading>
<Text size={2} style={{ color: 'var(--label-secondary)' }}>Shown in invitations and in the browser tab.</Text>
</div>
<div style={{ display: 'grid', gap: 'var(--spacing-8)', alignSelf: 'stretch' }}>
<div style={{ display: 'grid', gap: 'var(--spacing-3)' }}>
<Label htmlFor='workspace-card-name'>Name</Label>
<TextField>
<TextFieldInput id='workspace-card-name' name='workspace' defaultValue='Northwind Studio' required />
</TextField>
</div>
<div style={{ display: 'grid', gap: 'var(--spacing-3)' }}>
<Label htmlFor='workspace-card-slug'>URL</Label>
<TextField>
<TextFieldInput id='workspace-card-slug' name='slug' defaultValue='northwind' pattern='[a-z0-9-]{3,32}' required />
</TextField>
</div>
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: 'var(--spacing-4)', alignSelf: 'stretch' }}>
<Button type='submit' size='sm'>Save</Button>
<Button type='reset' size='sm' variant='secondary' appearance='ghost'>Reset</Button>
<Text size={2} role='status' style={{ color: 'var(--label-secondary)', overflowWrap: 'anywhere' }}>{saved}</Text>
</div>
</form>
</Card>
);
}Раскладка/оверлей
Раскладка/оверлей: адаптивная сетка с выровненными действиями
auto-fill с minmax(min(220px, 100%), 1fr) даёт столько колонок, сколько помещается, и не вызывает переполнения на телефонах. Строки сетки растягивают карточки до одной высоты, а margin-block-start: auto прижимает кнопки к низу независимо от длины текста.
import { Button, Card, Heading, Text } from '@vega-ui/react';
const integrations = [
{ id: 'slack', name: 'Slack', description: 'Post deployment and incident updates to channels.' },
{ id: 'github', name: 'GitHub', description: 'Link pull requests to releases and show build status on every commit, including drafts and forks.' },
{ id: 'pagerduty', name: 'PagerDuty Incident Response Orchestration', description: 'Page the on-call engineer.' },
];
export default function ResponsiveGrid() {
return (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(min(220px, 100%), 1fr))', gap: 'var(--spacing-6)', width: '100%' }}>
{integrations.map((integration) => (
<Card key={integration.id} size='sm' aria-labelledby={`integration-${integration.id}`} style={{ gap: 'var(--spacing-4)' }}>
<Heading id={`integration-${integration.id}`} as='h3' size={3} fontWeight={600}>{integration.name}</Heading>
<Text size={2} style={{ color: 'var(--label-secondary)' }}>{integration.description}</Text>
<Button size='sm' variant='secondary' appearance='ghost' aria-describedby={`integration-${integration.id}`} style={{ marginBlockStart: 'auto' }}>
Connect
</Button>
</Card>
))}
</div>
);
}Ошибка
Ошибка: данные не загрузились
Карточка сохраняет заголовок и объясняет сбой в области role='alert' с действием повтора. Повтор показывает состояние загрузки перед появлением данных.
import { useState } from 'react';
import { Button, Card, Heading, Spinner, Text } from '@vega-ui/react';
type State = 'failed' | 'loading' | 'loaded';
export default function LoadErrorCard() {
const [state, setState] = useState<State>('failed');
const retry = () => {
setState('loading');
setTimeout(() => setState('loaded'), 900);
};
return (
<Card aria-labelledby='usage-card-title' aria-busy={state === 'loading'} style={{ width: '100%', maxWidth: 340, gap: 'var(--spacing-4)' }}>
<Heading id='usage-card-title' as='h3' size={3} fontWeight={600}>API usage</Heading>
{state === 'loading' && <Spinner size={3} aria-label='Loading API usage' />}
{state === 'failed' && (
<div role='alert' style={{ display: 'grid', gap: 'var(--spacing-6)', justifyItems: 'start' }}>
<Text size={2} style={{ color: 'var(--label-secondary)' }}>Usage could not be loaded: the metrics service timed out.</Text>
<Button size='sm' variant='secondary' appearance='ghost' onClick={retry}>Try again</Button>
</div>
)}
{state === 'loaded' && (
<Text size={3}>1.82 M of 2 M requests used this month</Text>
)}
</Card>
);
}Отключённый
Отключённый: данные только для чтения для не-владельцев
У Card нет собственного отключённого состояния. <fieldset disabled> внутри карточки отключает все поля сразу, а объяснение связано через aria-describedby.
import { Card, Heading, Label, Text, TextField, TextFieldInput } from '@vega-ui/react';
export default function ReadOnlyBilling() {
return (
<Card aria-labelledby='billing-contact-title' style={{ width: '100%', maxWidth: 420, gap: 'var(--spacing-12)' }}>
<div style={{ display: 'grid', gap: 'var(--spacing-2)' }}>
<Heading id='billing-contact-title' as='h3' size={4} fontWeight={600}>Billing contact</Heading>
<Text id='billing-contact-note' size={2} style={{ color: 'var(--label-secondary)' }}>
Only workspace owners can change billing details. Ask Maria Chen to update them.
</Text>
</div>
<fieldset disabled aria-describedby='billing-contact-note' style={{ border: 0, margin: 0, padding: 0, display: 'grid', gap: 'var(--spacing-8)', alignSelf: 'stretch' }}>
<div style={{ display: 'grid', gap: 'var(--spacing-3)' }}>
<Label htmlFor='billing-contact-name'>Name</Label>
<TextField>
<TextFieldInput id='billing-contact-name' defaultValue='Maria Chen' />
</TextField>
</div>
<div style={{ display: 'grid', gap: 'var(--spacing-3)' }}>
<Label htmlFor='billing-contact-email'>Email</Label>
<TextField>
<TextFieldInput id='billing-contact-email' type='email' defaultValue='billing@northwind.io' />
</TextField>
</div>
</fieldset>
</Card>
);
}Пограничный случай
Пограничный случай: пустое состояние и сотни элементов
Одна и та же карточка справляется и с нулём, и с 240 элементами. Пустое состояние объясняет, что делать дальше; длинный список прокручивается внутри карточки, а область прокрутки фокусируема и имеет имя, чтобы её можно было прокрутить с клавиатуры.
import { useState } from 'react';
import { Button, Card, Heading, Text } from '@vega-ui/react';
const allKeys = Array.from({ length: 240 }, (_, index) => `sk_live_${String(index + 1).padStart(4, '0')}`);
export default function EmptyAndMany() {
const [keys, setKeys] = useState<string[]>([]);
const toggle = () => {
if (keys.length === 0) {
setKeys(allKeys);
return;
}
setKeys([]);
};
return (
<Card aria-labelledby='api-keys-title' style={{ width: '100%', maxWidth: 360, gap: 'var(--spacing-6)' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 'var(--spacing-4)', alignSelf: 'stretch' }}>
<Heading id='api-keys-title' as='h3' size={3} fontWeight={600} style={{ fontVariantNumeric: 'tabular-nums' }}>API keys ({keys.length})</Heading>
<Button size='sm' variant='secondary' appearance='ghost' onClick={toggle}>
{keys.length === 0 ? 'Load 240 keys' : 'Clear'}
</Button>
</div>
{keys.length === 0 && (
<Text size={2} style={{ color: 'var(--label-secondary)' }}>No API keys yet. Create one to call the API from your servers.</Text>
)}
{keys.length > 0 && (
<ul
tabIndex={0}
aria-label='API keys'
style={{ margin: 0, padding: 0, listStyle: 'none', maxHeight: 200, overflowY: 'auto', alignSelf: 'stretch' }}
>
{keys.map((key) => (
<li key={key} style={{ paddingBlock: 'var(--spacing-2)' }}>
<Text size={2} style={{ fontFamily: 'var(--code-font-family)' }}>{key}</Text>
</li>
))}
</ul>
)}
</Card>
);
}Анатомия
Обзор
Card — единый контейнер-поверхность для группировки без публичных дочерних частей. Его анатомия — одна корневая поверхность плюс заголовки, основной текст, метрики или действия, которые размещает потребитель.
Обязательные части
Card
Обязательна. Владеет оформлением поверхности, внутренними отступами и необязательным полиморфным рендером через asChild.
Необязательные части
Card не предоставляет публичных дочерних частей.
Порядок композиции
Card- заголовок, текст, действия или дочерние элементы раскладки
Допустимые варианты композиции
import { Button, Card, Heading, Text } from '@vega-ui/react';
const features = ['Up to 25 members', '100 GB of storage', 'Priority email support', 'SAML single sign-on'];
export default function BasicUsage() {
return (
<Card aria-labelledby='plan-team-title' style={{ width: '100%', maxWidth: 320, gap: 'var(--spacing-6)' }}>
<Heading id='plan-team-title' as='h3' size={4} fontWeight={600}>Team</Heading>
<Text size={3}>
<Text size={6} fontWeight={500}>$12</Text> per member / month
</Text>
<ul style={{ margin: 0, paddingInlineStart: 'var(--spacing-9)', display: 'grid', gap: 'var(--spacing-2)' }}>
{features.map((feature) => (
<li key={feature}><Text size={2} style={{ color: 'var(--label-secondary)' }}>{feature}</Text></li>
))}
</ul>
<Button fullWidth style={{ marginBlockStart: 'var(--spacing-6)' }}>Start 14-day trial</Button>
</Card>
);
}Недопустимые варианты композиции
Карточка как единственная семантическая структура
Поверхность не должна заменять заголовки, ориентиры или настоящую иерархию содержимого.
Прозрачная карточка на низкоконтрастном фоне
Сгруппированное содержимое может полностью потерять визуальное отделение.