Перейти к содержимому
v3.0.0
English
Русский
Документация

Table

Table — составной компонент для семантических табличных данных, когда нативная структура таблицы важнее взаимодействия с клавиатуры в стиле grid.
PlanPer month
Starter$9
Team$29
table.tsx · 18 строк
import {
  Table,
  TableBody,
  TableData,
  TableHead,
  TableHeading,
  TableRow,
} from '@vega-ui/react';

<Table fullWidth>
  <TableHead>
    <TableRow><TableHeading>Plan</TableHeading><TableHeading>Per month</TableHeading></TableRow>
  </TableHead>
  <TableBody>
    <TableRow><TableData>Starter</TableData><TableData>$9</TableData></TableRow>
    <TableRow><TableData>Team</TableData><TableData>$29</TableData></TableRow>
  </TableBody>
</Table>
Поддержка браузеров
Широко доступно
  • Chrome 111
  • Edge 111
  • Firefox 113
  • Safari 15.4
  • Safari на iOS 15.4
Размер в бандле
История версий
Добавлен в v1.0.0, последнее изменение в v3.0.0

Кратко

Table — составной компонент для семантических табличных данных, когда нативная структура таблицы важнее взаимодействия с клавиатуры в стиле grid.

Импорт

import {
  Table,
  TableHead,
  TableBody,
  TableRow,
  TableHeading,
  TableData,
  TableFoot,
  type TableProps,
} from '@vega-ui/react';

Экспортируемые типы

  • TableProps
  • TableHeadProps
  • TableBodyProps
  • TableRowProps
  • TableHeadingProps
  • TableDataProps
  • TableFootProps

Минимальная композиция

minimal-composition.tsx · 31 строка
import {
  Table,
  TableBody,
  TableData,
  TableHead,
  TableHeading,
  TableRow,
} from '@vega-ui/react';

export default function MinimalComposition() {
  return (
    <Table>
      <TableHead>
        <TableRow>
          <TableHeading scope='col'>Plan</TableHeading>
          <TableHeading scope='col'>Price</TableHeading>
        </TableRow>
      </TableHead>
      <TableBody>
        <TableRow>
          <TableData>Starter</TableData>
          <TableData>$9 / month</TableData>
        </TableRow>
        <TableRow>
          <TableData>Team</TableData>
          <TableData>$29 / month</TableData>
        </TableRow>
      </TableBody>
    </Table>
  );
}

Обязательные части

  • Table: корневая семантическая таблица и владелец выравнивания
  • TableRow: структура строки для секций
  • TableHeading или TableData: содержимое на уровне ячеек

Необязательные части

  • TableHead: секция заголовка для подписей столбцов
  • TableBody: секция тела для строк данных
  • TableFoot: секция подвала для сводок и итогов

Порядок композиции

  1. Table
  2. TableHead
  3. TableBody
  4. TableFoot
  5. TableRow
  6. TableHeading
  7. TableData

Варианты

  • Выравнивание данных через dataAlign
  • Варианты раскладки fullWidth и fullHeight
  • Края с отступами или вровень через edgePadded

Пограничные случаи

  • Table плохо подходит, когда интерфейсу нужны навигация с клавиатуры по ячейкам или сложные модели выбора.
  • На маленьких экранах потребителю нужно явно обработать переполнение или перестроить раскладку в столбик.
  • Выравнивание должно быть согласованным в ячейках заголовков и данных.

Типичные ошибки

  • Выбор DataGrid, когда достаточно семантической разметки таблицы.
  • Отказ от семантических секций вроде TableHead только ради упрощения стилей.
  • Игнорирование переполнения на маленьких экранах.

Примеры

Базовый

Базовый: сравнение тарифов с заголовками строк

Подпись (caption) называет таблицу, заголовки столбцов получают scope='col', а первая ячейка каждой строки — это TableHeading со scope='row', поэтому скринридер зачитывает каждое значение как «Single sign-on, Team». Иконки галочки и прочерка скрыты и дополнены визуально скрытым текстом «Included» / «Not included».

plan-comparison-table.tsx · 60 строк
import { Check, Minus } from '@vega-ui/icons';
import {
  Icon,
  Table,
  TableBody,
  TableData,
  TableHead,
  TableHeading,
  TableRow,
  VisuallyHidden,
} from '@vega-ui/react';

const plans = ['Starter', 'Team', 'Enterprise'];

const features: { name: string, values: (string | boolean)[] }[] = [
  { name: 'Price per seat', values: ['$9', '$29', 'Custom'] },
  { name: 'Projects', values: ['5', 'Unlimited', 'Unlimited'] },
  { name: 'Storage', values: ['10 GB', '1 TB', '5 TB'] },
  { name: 'Single sign-on', values: [false, true, true] },
  { name: 'Audit log', values: [false, false, true] },
];

export const layout = 'wide';

export default function PlanComparisonTable() {
  return (
    <div role='region' aria-label='Compare plans' tabIndex={0} style={{ width: '100%', overflowX: 'auto', border: '1px solid var(--separator-opaque)', borderRadius: 'var(--radius-4)', background: 'var(--surface-primary)' }}>
      <Table fullWidth edgePadded dataAlign='center' style={{ borderStyle: 'hidden' }}>
        <caption style={{ captionSide: 'top', textAlign: 'start', padding: 'var(--spacing-6)', paddingBlockEnd: 0, fontWeight: 600 }}>
          Compare plans
        </caption>
        <TableHead>
          <TableRow>
            <TableHeading scope='col' dataAlign='start'>Feature</TableHeading>
            {plans.map((plan) => <TableHeading key={plan} scope='col'>{plan}</TableHeading>)}
          </TableRow>
        </TableHead>
        <TableBody>
          {features.map((feature) => (
            <TableRow key={feature.name}>
              <TableHeading scope='row' dataAlign='start' style={{ fontWeight: 400, whiteSpace: 'nowrap' }}>{feature.name}</TableHeading>
              {feature.values.map((value, index) => {
                if (typeof value === 'string') {
                  return <TableData key={plans[index]}>{value}</TableData>;
                }

                return (
                  <TableData key={plans[index]}>
                    <Icon size='sm' aria-hidden='true'>{value ? <Check /> : <Minus />}</Icon>
                    <VisuallyHidden>{value ? 'Included' : 'Not included'}</VisuallyHidden>
                  </TableData>
                );
              })}
            </TableRow>
          ))}
        </TableBody>
      </Table>
    </div>
  );
}

Базовый: позиции счёта с вычисленными итогами

Промежуточный итог, налог и сумма к оплате вычисляются из позиций и попадают в TableFoot. Подписи — заголовки строк на два столбца, поэтому каждый итог остаётся связан со своей подписью.

summary-table-with-footer-totals.tsx · 59 строк
import {
  Table,
  TableBody,
  TableData,
  TableFoot,
  TableHead,
  TableHeading,
  TableRow,
} from '@vega-ui/react';

const lines = [
  { item: 'Team plan · 12 seats', quantity: 12, unitPrice: 29 },
  { item: 'Extra storage · 500 GB', quantity: 1, unitPrice: 40 },
  { item: 'Priority support', quantity: 1, unitPrice: 99 },
];

const currency = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' });

export default function SummaryTableWithFooterTotals() {
  const subtotal = lines.reduce((sum, line) => sum + line.quantity * line.unitPrice, 0);
  const tax = subtotal * 0.2;

  return (
    <div style={{ width: '100%', border: '1px solid var(--separator-opaque)', borderRadius: 'var(--radius-4)', background: 'var(--surface-primary)' }}>
      <Table fullWidth edgePadded style={{ borderStyle: 'hidden' }}>
        <TableHead>
          <TableRow>
            <TableHeading scope='col'>Item</TableHeading>
            <TableHeading scope='col'>Qty</TableHeading>
            <TableHeading scope='col'>Amount</TableHeading>
          </TableRow>
        </TableHead>
        <TableBody>
          {lines.map((line) => (
            <TableRow key={line.item}>
              <TableData>{line.item}</TableData>
              <TableData>{line.quantity}</TableData>
              <TableData style={{ fontVariantNumeric: 'tabular-nums' }}>{currency.format(line.quantity * line.unitPrice)}</TableData>
            </TableRow>
          ))}
        </TableBody>
        <TableFoot>
          <TableRow>
            <TableHeading scope='row' colSpan={2} dataAlign='start' style={{ fontWeight: 400 }}>Subtotal</TableHeading>
            <TableData style={{ fontVariantNumeric: 'tabular-nums' }}>{currency.format(subtotal)}</TableData>
          </TableRow>
          <TableRow>
            <TableHeading scope='row' colSpan={2} dataAlign='start' style={{ fontWeight: 400 }}>VAT 20%</TableHeading>
            <TableData style={{ fontVariantNumeric: 'tabular-nums' }}>{currency.format(tax)}</TableData>
          </TableRow>
          <TableRow>
            <TableHeading scope='row' colSpan={2} dataAlign='start'>Total due</TableHeading>
            <TableData style={{ fontWeight: 600, fontVariantNumeric: 'tabular-nums' }}>{currency.format(subtotal + tax)}</TableData>
          </TableRow>
        </TableFoot>
      </Table>
    </div>
  );
}

Контролируемый/с состоянием

Контролируемый/с состоянием: сортировка по столбцам

Столбец и направление сортировки хранятся в состоянии; в каждом сортируемом заголовке настоящая кнопка, а текущий порядок передаётся через aria-sort. Суммы сортируются как числа, даты — по ISO-значению, а в ячейках показан отформатированный текст.

sortable-columns.tsx · 96 строк
import { ArrowDown, ArrowUp, ArrowUpDown } from '@vega-ui/icons';
import {
  Button,
  Icon,
  Table,
  TableBody,
  TableData,
  TableHead,
  TableHeading,
  TableRow,
} from '@vega-ui/react';
import { useState } from 'react';

type Invoice = { id: string, customer: string, issued: string, amount: number };
type SortKey = 'customer' | 'issued' | 'amount';
type Direction = 'ascending' | 'descending';

const invoices: Invoice[] = [
  { id: 'INV-1042', customer: 'Northwind Traders', issued: '2026-10-02', amount: 1240 },
  { id: 'INV-1043', customer: 'Acme Design', issued: '2026-09-18', amount: 380 },
  { id: 'INV-1044', customer: 'Blue Harbor Cafe', issued: '2026-10-21', amount: 96.5 },
  { id: 'INV-1045', customer: 'Lumen Labs', issued: '2026-08-30', amount: 4210 },
];

const columns: { key: SortKey, label: string }[] = [
  { key: 'customer', label: 'Customer' },
  { key: 'issued', label: 'Issued' },
  { key: 'amount', label: 'Amount' },
];

const currency = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' });
const date = new Intl.DateTimeFormat('en-US', { dateStyle: 'medium', timeZone: 'UTC' });

export const layout = 'wide';

export default function SortableColumns() {
  const [sortKey, setSortKey] = useState<SortKey>('issued');
  const [direction, setDirection] = useState<Direction>('descending');

  const sorted = [...invoices].sort((a, b) => {
    let result = 0;
    if (sortKey === 'amount') {
      result = a.amount - b.amount;
    } else {
      result = a[sortKey].localeCompare(b[sortKey]);
    }
    return direction === 'ascending' ? result : -result;
  });

  const sortBy = (key: SortKey) => {
    if (key === sortKey) {
      setDirection(direction === 'ascending' ? 'descending' : 'ascending');
      return;
    }
    setSortKey(key);
    setDirection('ascending');
  };

  return (
    <div role='region' aria-label='Invoices' tabIndex={0} style={{ width: '100%', overflowX: 'auto', border: '1px solid var(--separator-opaque)', borderRadius: 'var(--radius-4)', background: 'var(--surface-primary)' }}>
      <Table fullWidth edgePadded style={{ borderStyle: 'hidden' }}>
        <TableHead>
          <TableRow>
            <TableHeading scope='col'>Invoice</TableHeading>
            {columns.map((column) => {
              const active = column.key === sortKey;
              let icon = <ArrowUpDown />;
              if (active) {
                icon = direction === 'ascending' ? <ArrowUp /> : <ArrowDown />;
              }

              return (
                <TableHeading key={column.key} scope='col' aria-sort={active ? direction : undefined}>
                  <Button size='sm' variant='secondary' appearance='transparent' onClick={() => sortBy(column.key)}>
                    {column.label}
                    <Icon size='xs' aria-hidden='true'>{icon}</Icon>
                  </Button>
                </TableHeading>
              );
            })}
          </TableRow>
        </TableHead>
        <TableBody>
          {sorted.map((invoice) => (
            <TableRow key={invoice.id}>
              <TableData style={{ whiteSpace: 'nowrap' }}>{invoice.id}</TableData>
              <TableData style={{ whiteSpace: 'nowrap' }}>{invoice.customer}</TableData>
              <TableData style={{ whiteSpace: 'nowrap' }}>{date.format(new Date(invoice.issued))}</TableData>
              <TableData style={{ whiteSpace: 'nowrap', fontVariantNumeric: 'tabular-nums' }}>{currency.format(invoice.amount)}</TableData>
            </TableRow>
          ))}
        </TableBody>
      </Table>
    </div>
  );
}

Форма/интеграция

Форма/интеграция: выбор строк для массового действия

В каждой строке именованный чекбокс (name='member', value — id строки), а чекбокс в заголовке выбирает все строки или показывает неопределённое состояние при частичном выборе. При отправке обработчик собирает выбранные id через FormData.getAll.

row-selection-form.tsx · 99 строк
import {
  Button,
  Checkbox,
  CheckboxCheckedIcon,
  CheckboxIndeterminateIcon,
  CheckboxIndicator,
  CheckboxInput,
  HelperText,
  Table,
  TableBody,
  TableData,
  TableHead,
  TableHeading,
  TableRow,
} from '@vega-ui/react';
import { FormEvent, useState } from 'react';

const members = [
  { id: 'u-17', name: 'Maya Chen', email: 'maya@acme.io' },
  { id: 'u-23', name: 'Omar Haddad', email: 'omar@acme.io' },
  { id: 'u-31', name: 'Lena Fischer', email: 'lena@acme.io' },
  { id: 'u-42', name: 'Tomás Ruiz', email: 'tomas@acme.io' },
];

export default function RowSelectionForm() {
  const [selected, setSelected] = useState<string[]>([]);
  const [message, setMessage] = useState('');

  const allSelected = selected.length === members.length;
  const someSelected = selected.length > 0 && !allSelected;

  const toggle = (id: string) => {
    if (selected.includes(id)) {
      setSelected(selected.filter((item) => item !== id));
      return;
    }
    setSelected([...selected, id]);
  };

  const toggleAll = () => {
    setSelected(allSelected ? [] : members.map((member) => member.id));
  };

  const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
    event.preventDefault();
    const ids = new FormData(event.currentTarget).getAll('member');
    setMessage(`Sent a reminder to ${ids.length} ${ids.length === 1 ? 'member' : 'members'}: ${ids.join(', ')}.`);
  };

  return (
    <form onSubmit={handleSubmit} style={{ display: 'grid', gap: 'var(--spacing-8)', width: '100%' }}>
      <div role='region' aria-label='Members' tabIndex={0} style={{ width: '100%', overflowX: 'auto', border: '1px solid var(--separator-opaque)', borderRadius: 'var(--radius-4)', background: 'var(--surface-primary)' }}>
        <Table fullWidth edgePadded dataAlign='start' style={{ borderStyle: 'hidden' }}>
          <TableHead>
            <TableRow>
              <TableHeading scope='col' style={{ width: 40 }}>
                <Checkbox>
                  <CheckboxInput aria-label='Select all members' checked={allSelected} indeterminate={someSelected} onChange={toggleAll} />
                  <CheckboxIndicator>
                    <CheckboxCheckedIcon />
                    <CheckboxIndeterminateIcon />
                  </CheckboxIndicator>
                </Checkbox>
              </TableHeading>
              <TableHeading scope='col'>Name</TableHeading>
              <TableHeading scope='col'>Email</TableHeading>
            </TableRow>
          </TableHead>
          <TableBody>
            {members.map((member) => (
              <TableRow key={member.id}>
                <TableData>
                  <Checkbox>
                    <CheckboxInput
                      name='member'
                      value={member.id}
                      aria-label={`Select ${member.name}`}
                      checked={selected.includes(member.id)}
                      onChange={() => toggle(member.id)}
                    />
                    <CheckboxIndicator>
                      <CheckboxCheckedIcon />
                    </CheckboxIndicator>
                  </Checkbox>
                </TableData>
                <TableData style={{ whiteSpace: 'nowrap' }}>{member.name}</TableData>
                <TableData style={{ whiteSpace: 'nowrap' }}>{member.email}</TableData>
              </TableRow>
            ))}
          </TableBody>
        </Table>
      </div>
      <div style={{ display: 'flex', alignItems: 'center', gap: 'var(--spacing-8)' }}>
        <Button type='submit' size='sm' disabled={selected.length === 0} style={{ flexShrink: 0 }}>Send reminder</Button>
        <HelperText role='status'>{message || `${selected.length} selected`}</HelperText>
      </div>
    </form>
  );
}

Раскладка/оверлей

Раскладка/оверлей: прокручиваемая таблица в диалоге

Длинный список начислений внутри диалога прокручивается в собственной фокусируемой области и не растягивает диалог. Ячейки заголовка липкие и окрашены в цвет поверхности диалога, поэтому строки под ними не просвечивают.

billing-table-inside-a-dialog.tsx · 71 строка
import {
  Button,
  Dialog,
  DialogClose,
  DialogContent,
  DialogTrigger,
  Heading,
  Icon,
  IconButton,
  Table,
  TableBody,
  TableData,
  TableHead,
  TableHeading,
  TableRow,
} from '@vega-ui/react';
import { XIcon } from '@vega-ui/icons';
import { CSSProperties, useId } from 'react';

const charges = Array.from({ length: 18 }, (_, index) => ({
  date: `Oct ${index + 1}`,
  description: index % 3 === 0 ? 'Build minutes overage' : 'API requests',
  amount: 4 + ((index * 7) % 23),
}));

export default function BillingTableInsideADialog() {
  const titleId = useId();

  return (
    <Dialog>
      <DialogTrigger asChild>
        <Button variant='secondary' appearance='ghost'>View October charges</Button>
      </DialogTrigger>
      <DialogContent aria-labelledby={titleId} style={{ display: 'grid', gap: 'var(--spacing-8)' }}>
        <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 'var(--spacing-8)' }}>
          <Heading id={titleId} as='h2' size={4} fontWeight={600}>October usage charges</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 role='region' aria-labelledby={titleId} tabIndex={0} style={{ maxHeight: 280, overflowY: 'auto' }}>
          <Table fullWidth>
            <TableHead>
              <TableRow>
                <TableHeading scope='col' style={{ position: 'sticky', insetBlockStart: 0, background: 'var(--surface-primary)' }}>Date</TableHeading>
                <TableHeading scope='col' style={{ position: 'sticky', insetBlockStart: 0, background: 'var(--surface-primary)' }}>Description</TableHeading>
                <TableHeading scope='col' style={{ position: 'sticky', insetBlockStart: 0, background: 'var(--surface-primary)' }}>Amount</TableHeading>
              </TableRow>
            </TableHead>
            <TableBody>
              {charges.map((charge) => (
                <TableRow key={charge.date}>
                  <TableData>{charge.date}</TableData>
                  <TableData>{charge.description}</TableData>
                  <TableData style={{ fontVariantNumeric: 'tabular-nums' }}>${charge.amount}.00</TableData>
                </TableRow>
              ))}
            </TableBody>
          </Table>
        </div>
      </DialogContent>
    </Dialog>
  );
}

Ошибка

Ошибка: строки не загрузились

Состояния загрузки, ошибки и данных живут в одной таблице: строки загрузки и ошибки растянуты на все столбцы через colSpan={columns.length}. Ошибка объявляется через role='alert', а Retry загружает строки со второй попытки.

failed-to-load-rows.tsx · 78 строк
import {
  Button,
  HelperText,
  Spinner,
  Table,
  TableBody,
  TableData,
  TableHead,
  TableHeading,
  TableRow,
} from '@vega-ui/react';
import { useEffect, useState } from 'react';

type Status = 'loading' | 'error' | 'ready';

const columns = ['Deploy', 'Branch', 'Duration'];

const deploys = [
  { id: '#4182', branch: 'main', duration: '3m 12s' },
  { id: '#4181', branch: 'fix/invoice-rounding', duration: '2m 47s' },
  { id: '#4180', branch: 'main', duration: '3m 05s' },
];

export default function FailedToLoadRows() {
  const [status, setStatus] = useState<Status>('loading');
  const [attempt, setAttempt] = useState(1);

  useEffect(() => {
    if (status !== 'loading') return;

    const timer = setTimeout(() => setStatus(attempt === 1 ? 'error' : 'ready'), 1000);

    return () => clearTimeout(timer);
  }, [status, attempt]);

  const retry = () => {
    setAttempt(attempt + 1);
    setStatus('loading');
  };

  return (
    <div style={{ width: '100%', border: '1px solid var(--separator-opaque)', borderRadius: 'var(--radius-4)', background: 'var(--surface-primary)' }}>
      <Table fullWidth edgePadded aria-busy={status === 'loading'} style={{ borderStyle: 'hidden' }}>
        <TableHead>
          <TableRow>
            {columns.map((column) => <TableHeading key={column} scope='col'>{column}</TableHeading>)}
          </TableRow>
        </TableHead>
        <TableBody>
          {status === 'loading' && (
            <TableRow>
              <TableData colSpan={columns.length} dataAlign='center' style={{ height: 'var(--spacing-52)' }}>
                <Spinner size={3} aria-label='Loading deploys' style={{ marginInline: 'auto' }} />
              </TableData>
            </TableRow>
          )}
          {status === 'error' && (
            <TableRow>
              <TableData colSpan={columns.length} dataAlign='center' style={{ height: 'var(--spacing-52)' }}>
                <div style={{ display: 'grid', gap: 'var(--spacing-6)', justifyItems: 'center' }}>
                  <HelperText error role='alert'>Could not load deploys: the request timed out.</HelperText>
                  <Button size='sm' variant='secondary' appearance='ghost' onClick={retry}>Retry</Button>
                </div>
              </TableData>
            </TableRow>
          )}
          {status === 'ready' && deploys.map((deploy) => (
            <TableRow key={deploy.id}>
              <TableData>{deploy.id}</TableData>
              <TableData>{deploy.branch}</TableData>
              <TableData>{deploy.duration}</TableData>
            </TableRow>
          ))}
        </TableBody>
      </Table>
    </div>
  );
}

Отключённый

Отключённый: архивные строки с заблокированными действиями

Архивные строки получают вторичный цвет текста и бейдж «Archived», так что состояние передаётся не только цветом, а действия в них отключены. У каждой кнопки действия доступное имя включает название проекта.

visually-subdued-archived-rows.tsx · 53 строки
import {
  Badge,
  Button,
  Table,
  TableBody,
  TableData,
  TableHead,
  TableHeading,
  TableRow,
} from '@vega-ui/react';

const projects = [
  { id: 'p1', name: 'Mobile app redesign', owner: 'Maya Chen', archived: false },
  { id: 'p2', name: 'Spring campaign 2025', owner: 'Omar Haddad', archived: true },
  { id: 'p3', name: 'Billing migration', owner: 'Lena Fischer', archived: false },
  { id: 'p4', name: 'Legacy API docs', owner: 'Tomás Ruiz', archived: true },
];

export const layout = 'wide';

export default function VisuallySubduedArchivedRows() {
  return (
    <div role='region' aria-label='Projects' tabIndex={0} style={{ width: '100%', overflowX: 'auto', border: '1px solid var(--separator-opaque)', borderRadius: 'var(--radius-4)', background: 'var(--surface-primary)' }}>
      <Table fullWidth edgePadded style={{ borderStyle: 'hidden' }}>
        <TableHead>
          <TableRow>
            <TableHeading scope='col'>Project</TableHeading>
            <TableHeading scope='col'>Owner</TableHeading>
            <TableHeading scope='col'>Actions</TableHeading>
          </TableRow>
        </TableHead>
        <TableBody>
          {projects.map((project) => (
            <TableRow key={project.id} style={project.archived ? { color: 'var(--label-secondary)' } : undefined}>
              <TableData>
                <span style={{ display: 'inline-flex', alignItems: 'center', gap: 'var(--spacing-4)' }}>
                  {project.name}
                  {project.archived && <Badge size='sm' appearance='surface'>Archived</Badge>}
                </span>
              </TableData>
              <TableData style={{ whiteSpace: 'nowrap' }}>{project.owner}</TableData>
              <TableData>
                <Button size='sm' variant='secondary' appearance='ghost' disabled={project.archived} aria-label={`Edit ${project.name}`}>
                  Edit
                </Button>
              </TableData>
            </TableRow>
          ))}
        </TableBody>
      </Table>
    </div>
  );
}

Пограничный случай

Пограничный случай: пустой результат после фильтрации

Если поиск ничего не нашёл, в теле таблицы остаётся одна строка на все столбцы с пояснением и выходом из ситуации. Заголовки остаются на месте, и таблица не схлопывается.

empty-state-row-when-data-is-unavailable.tsx · 68 строк
import {
  Button,
  Label,
  Table,
  TableBody,
  TableData,
  TableHead,
  TableHeading,
  TableRow,
  Text,
  TextField,
  TextFieldInput,
} from '@vega-ui/react';
import { useId, useState } from 'react';

const columns = ['Customer', 'Country', 'Orders'];

const customers = [
  { name: 'Northwind Traders', country: 'Germany', orders: 42 },
  { name: 'Blue Harbor Cafe', country: 'Portugal', orders: 7 },
  { name: 'Lumen Labs', country: 'Canada', orders: 19 },
];

export default function EmptyStateRowWhenDataIsUnavailable() {
  const fieldId = useId();
  const [query, setQuery] = useState('zz');

  const matches = customers.filter((customer) => customer.name.toLowerCase().includes(query.trim().toLowerCase()));

  return (
    <div style={{ display: 'grid', gap: 'var(--spacing-12)', width: '100%' }}>
      <div style={{ display: 'grid', gap: 'var(--spacing-3)' }}>
        <Label htmlFor={fieldId}>Search customers</Label>
        <TextField>
          <TextFieldInput id={fieldId} type='search' value={query} onChange={(event) => setQuery(event.target.value)} />
        </TextField>
      </div>
      <div style={{ width: '100%', border: '1px solid var(--separator-opaque)', borderRadius: 'var(--radius-4)', background: 'var(--surface-primary)' }}>
        <Table fullWidth edgePadded style={{ borderStyle: 'hidden' }}>
          <TableHead>
            <TableRow>
              {columns.map((column) => <TableHeading key={column} scope='col'>{column}</TableHeading>)}
            </TableRow>
          </TableHead>
          <TableBody>
            {matches.length === 0 && (
              <TableRow>
                <TableData colSpan={columns.length} dataAlign='center'>
                  <div style={{ display: 'grid', gap: 'var(--spacing-6)', justifyItems: 'center', paddingBlock: 'var(--spacing-8)' }}>
                    <Text size={2} style={{ color: 'var(--label-secondary)' }}>No customers match “{query}”.</Text>
                    <Button size='sm' variant='secondary' appearance='ghost' onClick={() => setQuery('')}>Clear search</Button>
                  </div>
                </TableData>
              </TableRow>
            )}
            {matches.map((customer) => (
              <TableRow key={customer.name}>
                <TableData>{customer.name}</TableData>
                <TableData>{customer.country}</TableData>
                <TableData style={{ fontVariantNumeric: 'tabular-nums' }}>{customer.orders}</TableData>
              </TableRow>
            ))}
          </TableBody>
        </Table>
      </div>
    </div>
  );
}

Пограничный случай: 200 строк и 8 столбцов в контейнере прокрутки

Таблица сохраняет естественную ширину и прокручивается в обе стороны внутри role='region' с tabIndex={0}, поэтому прокрутить её можно и с клавиатуры. Строка заголовка прилипает к верху при прокрутке.

scrollable-wide-table.tsx · 58 строк
import {
  Table,
  TableBody,
  TableData,
  TableHead,
  TableHeading,
  TableRow,
  Text,
} from '@vega-ui/react';
import { CSSProperties, useId } from 'react';

const columns = ['Order', 'Customer', 'City', 'Items', 'Subtotal', 'Shipping', 'Status', 'Placed'];
const cities = ['Lisbon', 'Berlin', 'Toronto', 'Osaka', 'Austin'];
const statuses = ['Paid', 'Shipped', 'Delivered', 'Refunded'];

const rows = Array.from({ length: 200 }, (_, index) => [
  `#${20000 + index}`,
  `Customer ${index + 1}`,
  cities[index % cities.length],
  String(1 + (index % 6)),
  `$${(19 + ((index * 13) % 300)).toFixed(2)}`,
  index % 4 === 0 ? 'Free' : '$4.90',
  statuses[index % statuses.length],
  `Oct ${1 + (index % 31)}, 2026`,
]);

const stickyHeading: CSSProperties = {
  position: 'sticky',
  insetBlockStart: 0,
  background: 'var(--surface-primary)',
  whiteSpace: 'nowrap',
};

export default function ScrollableWideTable() {
  const captionId = useId();

  return (
    <div style={{ display: 'grid', gap: 'var(--spacing-3)', width: '100%' }}>
      <Text id={captionId} size={2} fontWeight={500}>All orders ({rows.length})</Text>
      <div role='region' aria-labelledby={captionId} tabIndex={0} style={{ maxHeight: 320, overflow: 'auto', border: '1px solid var(--separator-opaque)', borderRadius: 'var(--radius-4)', background: 'var(--surface-primary)' }}>
        <Table edgePadded style={{ borderStyle: 'hidden' }}>
          <TableHead>
            <TableRow>
              {columns.map((column) => <TableHeading key={column} scope='col' style={stickyHeading}>{column}</TableHeading>)}
            </TableRow>
          </TableHead>
          <TableBody>
            {rows.map((row) => (
              <TableRow key={row[0]}>
                {row.map((cell, index) => <TableData key={columns[index]} style={{ whiteSpace: 'nowrap' }}>{cell}</TableData>)}
              </TableRow>
            ))}
          </TableBody>
        </Table>
      </div>
    </div>
  );
}

Пограничный случай: длинный неразрывный текст

С table-layout: fixed и ширинами столбцов в colgroup длинное имя файла переносится в любом месте (overflow-wrap: anywhere), а email обрезается многоточием и хранит полное значение в title. Узкие столбцы при этом не сжимаются.

long-cell-content.tsx · 48 строк
import {
  Table,
  TableBody,
  TableData,
  TableHead,
  TableHeading,
  TableRow,
} from '@vega-ui/react';

const files = [
  { name: 'q3-board-presentation-final-v7-reviewed-by-legal-and-finance-DO-NOT-SHARE.pdf', owner: 'maya.chen@northwind-traders-international.example.com', size: '18.4 MB' },
  { name: 'logo.svg', owner: 'li@acme.io', size: '4 KB' },
  { name: 'Отчёт_о_продажах_за_третий_квартал_по_регионам.xlsx', owner: 'ivan.petrov@example.ru', size: '2.1 MB' },
];

export const layout = 'wide';

export default function LongCellContent() {
  return (
    <div style={{ width: '100%', border: '1px solid var(--separator-opaque)', borderRadius: 'var(--radius-4)', background: 'var(--surface-primary)' }}>
      <Table fullWidth edgePadded style={{ tableLayout: 'fixed', borderStyle: 'hidden' }}>
        <colgroup>
          <col />
          <col style={{ width: '35%' }} />
          <col style={{ width: 88 }} />
        </colgroup>
        <TableHead>
          <TableRow>
            <TableHeading scope='col'>File</TableHeading>
            <TableHeading scope='col'>Owner</TableHeading>
            <TableHeading scope='col'>Size</TableHeading>
          </TableRow>
        </TableHead>
        <TableBody>
          {files.map((file) => (
            <TableRow key={file.name}>
              <TableData style={{ overflowWrap: 'anywhere' }}>{file.name}</TableData>
              <TableData title={file.owner} style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
                {file.owner}
              </TableData>
              <TableData style={{ whiteSpace: 'nowrap' }}>{file.size}</TableData>
            </TableRow>
          ))}
        </TableBody>
      </Table>
    </div>
  );
}

Пограничный случай: ячейки вровень с краями карточки

По умолчанию edgePadded равен false: первая и последняя ячейки теряют внешний отступ, и текст выравнивается по заголовку карточки. Переключите свойство, чтобы сравнить с отступами по краям.

flush-edges-without-extra-padding.tsx · 41 строка
import {
  Button,
  Card,
  Heading,
  Table,
  TableBody,
  TableData,
  TableRow,
} from '@vega-ui/react';
import { useState } from 'react';

const details = [
  ['Plan', 'Team'],
  ['Billing cycle', 'Yearly'],
  ['Next invoice', '1 December 2026'],
];

export default function FlushEdgesWithoutExtraPadding() {
  const [edgePadded, setEdgePadded] = useState(false);

  return (
    <div style={{ display: 'grid', gap: 'var(--spacing-8)', justifyItems: 'start', width: '100%' }}>
      <Button size='sm' variant='secondary' appearance='ghost' aria-pressed={edgePadded} onClick={() => setEdgePadded(!edgePadded)}>
        edgePadded: {String(edgePadded)}
      </Button>
      <Card appearance='outline' size='sm' style={{ alignItems: 'stretch', width: '100%', maxWidth: 360 }}>
        <Heading as='h3' size={4} fontWeight={600}>Subscription</Heading>
        <Table fullWidth edgePadded={edgePadded}>
          <TableBody>
            {details.map(([label, value]) => (
              <TableRow key={label}>
                <TableData>{label}</TableData>
                <TableData>{value}</TableData>
              </TableRow>
            ))}
          </TableBody>
        </Table>
      </Card>
    </div>
  );
}

Анатомия

Обзор

Table владеет общим контекстом выравнивания и краевых отступов для семантической структуры таблицы. Это составной компонент, потому что корень и секции работают вместе по одному контракту отображения данных.

Обязательные части

Table

Обязательная. Отвечает за выравнивание, краевые отступы и флаги раскладки на всю ширину или высоту.

TableRow

Обязательная. Задаёт структуру строки внутри секций заголовка, тела или подвала.

TableHeading или TableData

Обязательная. Предоставляет сами семантические ячейки строки.

Необязательные части

TableHead

Рекомендуется для подписей столбцов и доступной структуры столбцов.

TableBody

Рекомендуется для строк данных.

TableFoot

Необязательный подвал для итогов, сводок или агрегированных значений.

Порядок композиции

  1. Table
  2. TableHead
  3. TableBody
  4. TableFoot
  5. TableRow
  6. TableHeading
  7. TableData

Корректные паттерны композиции

summary-table-with-footer-totals.tsx · 59 строк
import {
  Table,
  TableBody,
  TableData,
  TableFoot,
  TableHead,
  TableHeading,
  TableRow,
} from '@vega-ui/react';

const lines = [
  { item: 'Team plan · 12 seats', quantity: 12, unitPrice: 29 },
  { item: 'Extra storage · 500 GB', quantity: 1, unitPrice: 40 },
  { item: 'Priority support', quantity: 1, unitPrice: 99 },
];

const currency = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' });

export default function SummaryTableWithFooterTotals() {
  const subtotal = lines.reduce((sum, line) => sum + line.quantity * line.unitPrice, 0);
  const tax = subtotal * 0.2;

  return (
    <div style={{ width: '100%', border: '1px solid var(--separator-opaque)', borderRadius: 'var(--radius-4)', background: 'var(--surface-primary)' }}>
      <Table fullWidth edgePadded style={{ borderStyle: 'hidden' }}>
        <TableHead>
          <TableRow>
            <TableHeading scope='col'>Item</TableHeading>
            <TableHeading scope='col'>Qty</TableHeading>
            <TableHeading scope='col'>Amount</TableHeading>
          </TableRow>
        </TableHead>
        <TableBody>
          {lines.map((line) => (
            <TableRow key={line.item}>
              <TableData>{line.item}</TableData>
              <TableData>{line.quantity}</TableData>
              <TableData style={{ fontVariantNumeric: 'tabular-nums' }}>{currency.format(line.quantity * line.unitPrice)}</TableData>
            </TableRow>
          ))}
        </TableBody>
        <TableFoot>
          <TableRow>
            <TableHeading scope='row' colSpan={2} dataAlign='start' style={{ fontWeight: 400 }}>Subtotal</TableHeading>
            <TableData style={{ fontVariantNumeric: 'tabular-nums' }}>{currency.format(subtotal)}</TableData>
          </TableRow>
          <TableRow>
            <TableHeading scope='row' colSpan={2} dataAlign='start' style={{ fontWeight: 400 }}>VAT 20%</TableHeading>
            <TableData style={{ fontVariantNumeric: 'tabular-nums' }}>{currency.format(tax)}</TableData>
          </TableRow>
          <TableRow>
            <TableHeading scope='row' colSpan={2} dataAlign='start'>Total due</TableHeading>
            <TableData style={{ fontWeight: 600, fontVariantNumeric: 'tabular-nums' }}>{currency.format(subtotal + tax)}</TableData>
          </TableRow>
        </TableFoot>
      </Table>
    </div>
  );
}

Некорректные паттерны композиции

Визуальная раскладка таблицы без семантических частей

Это ослабляет нативный смысл таблицы и часто вредит доступности.

Произвольное смешение ролей заголовков и данных

Подписи столбцов должны оставаться в TableHeading, а не превращаться в стилизованные ячейки данных.

Table как интерактивный грид данных

Если функции нужны навигация по ячейкам, модели выбора или более богатое поведение грида, выбирайте DataGrid.