Skip to content
v2.5.0
English
Русский
Documentation

Card

Card is a surface container for grouped content such as settings panels, dashboard blocks, and inline summaries.

Weekly report

Sent every Monday at 9:00 to 4 people.
card.tsx · 6 lines
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>
Browser support
Widely available
  • Chrome 111
  • Edge 111
  • Firefox 113
  • Safari 15.4
  • Safari on iOS 15.4
Bundle size
Tested in
14 tests

Summary

Card is a surface container for grouped content such as settings panels, dashboard blocks, and inline summaries. It is a layout and grouping surface, not a feedback or status primitive.

Imports

import {
  Card,
  type CardProps,
  type CardSize,
  type CardAppearance,
} from '@vega-ui/react';

Exported Types

  • CardProps
  • CardSize
  • CardAppearance

Basic Usage

basic-usage.tsx · 20 lines
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)' }}>

Variants

  • Sizes: sm, md, lg
  • Appearances: outline, transparent
  • Composition: default article or asChild

Edge Cases

  • Card is a layout surface, not a semantic replacement for headings or sections.
  • Transparent cards work only when the parent surface already provides contrast.
  • Internal spacing still belongs to child layout decisions.
  • If the UI is trying to communicate status or urgency rather than grouping content, Alert or Badge is usually the clearer abstraction.

Common Mistakes

  • Expecting Card to provide all internal layout spacing automatically.
  • Using transparent cards on visually noisy backgrounds.
  • Encoding hierarchy only through card appearance.

Examples

Basic

Basic: pricing plan

Card renders an article, named by its heading through aria-labelledby. Content stacks in a column; spacing between children comes from gap on the card.

basic-usage.tsx · 20 lines
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)' }}>

With asChild the card styles move onto an <a>, so the entire surface is one link with one accessible name. Keep the content of a link card non-interactive: no buttons or nested links inside.

link-cards.tsx · 27 lines
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%' }}>

Controlled/Stateful

Controlled/Stateful: metric card with refresh

The value, the timestamp and the loading flag live in state. While refreshing, the card is aria-busy, the spinner has its own accessible name and the button is disabled.

metrics-card.tsx · 32 lines
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);

Form/Integration

Form/Integration: the card is the form

asChild renders the card as a <form>, named by its heading. Values are read with FormData on submit, native required and pattern validate the fields, and reset clears the status message.

settings-form-card.tsx · 42 lines
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`);
  };

Layout/Overlay

Layout/Overlay: responsive grid with aligned actions

auto-fill with minmax(min(220px, 100%), 1fr) gives as many columns as fit without overflowing on phones. Grid rows stretch the cards to equal height, and margin-block-start: auto keeps every button at the bottom regardless of text length.

responsive-grid.tsx · 23 lines
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) => (

Error

Error: data failed to load

The card keeps its heading and explains the failure in a role='alert' region with a retry action. The retry shows a loading state before the data appears.

load-error-card.tsx · 29 lines
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);
  };

Disabled

Disabled: read-only details for non-owners

Card has no disabled state of its own. A <fieldset disabled> inside it disables every field at once, and the explanation is linked with aria-describedby.

read-only-billing.tsx · 28 lines
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' }}>

Edge

Edge: empty state and hundreds of items

The same card handles zero and 240 items. The empty state explains what to do next; the long list scrolls inside the card, and the scroll area is focusable and named so keyboard users can scroll it.

empty-and-many.tsx · 43 lines
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;

Anatomy

Overview

Card is a single grouped surface container with no public child parts. Its anatomy is one root surface plus whatever heading, body, metrics, or actions the consumer places inside.

Required Parts

Card

Required. Owns surface appearance, padding, and optional polymorphic rendering through asChild.

Optional Parts

Card does not expose public child parts.

Composition Order

  1. Card
  2. heading, text, actions, or layout children

Valid Composition Patterns

basic-usage.tsx · 20 lines
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)' }}>

Invalid Composition Patterns

Card used as the only semantic structure

The surface should not replace headings, landmarks, or real content hierarchy.

Transparent card on a low-contrast background

The grouped content can lose visual separation entirely.