Tables

Grouped Header

A table with a two-storey header: the top row groups columns by year with colspan, the bottom row names the columns inside a year, and the first cell is merged with rowspan.

  • table
  • header
  • colspan
  • report

Preview

1440pxHost theme

Use it with AI

  1. 1. Copy the link.
  2. 2. Write to your agent in your own words and drop the link into the sentence.
  3. 3. The agent opens the link and installs the component from the registry.

put this in the header: https://vibeui.ru/c/table-014?lang=en

Revenue by region, m
Region20242025
ПланФактПланФакт
Северо-Запад12,011,413,514,2
Центр28,430,132,031,6
Урал9,89,110,410,9
Сибирь7,27,78,18,0
Show the full instructions

What the link says, spelled out. Use it when your agent cannot open links — paste this text instead.

# Install and place "table-014" (Grouped Header) from VibeUI

## 1. Install first — do not skip, do not recreate
Run this exact command before writing any code:
npx shadcn@latest add https://vibeui.ru/r/table-014.json

Registry item: https://vibeui.ru/r/table-014.json
Installs to: components/vibeui/table-014.tsx (the exact path follows this project's components.json aliases).
npm dependencies: none.
Registry dependencies: none.

Install it from the registry. Do not recreate it from the description,
do not substitute a similar component from another library, and do not
rewrite it to match the project's existing style.

## 2. What it is
A table with a two-storey header: the top row groups columns by year with colspan, the bottom row names the columns inside a year, and the first cell is merged with rowspan.

A report table with column groups: two header rows, colspan, rowspan and scope="colgroup". Zero dependencies, one file, no client JS.

## 3. How to use it
import { Table014 } from "@/components/vibeui/table-014"

<Table014
  caption="Revenue by region, m"
  leadTitle="Region"
/>

Read the installed file for the full prop list.

## 4. Where to place it
This is a small inline component. Put it exactly where the user asked,
inside the existing markup. Do not create a new page, section or
wrapper for it. If a similar control already sits in that spot,
replace it instead of adding a second one.

Placement: ___
(The user fills this line in. If it is still blank, ask where to put it
instead of guessing.)

## 5. Keep exactly as installed
- the local --vibeui-table-014-* palette — do not swap it for your theme tokens
- scope="colgroup" on the upper header row: without it a number loses the year it belongs to
- rowSpan={2} on the first cell: otherwise the row-header caption hangs over nothing
- building columns from groups with flatMap: a hand-written cell list eventually drifts from the header
- the divider between groups: two identical "Plan" columns merge into one without it
- the component's own light surface: dark text disappears on the dark catalog card

## 6. You may change
- the groups and their columns through groups
- the rows and their values through rows
- the first column caption through leadTitle
- the table caption through caption

## 7. Rules
- Value order in a row must match column order in the groups: the mapping is positional.
- A third header storey is not drawn — it needs one more row and a scope on every cell.
- A missing value prints a dash rather than an empty cell: emptiness reads as a layout bug.
- Do not lift the CSS variables into globals.css: the component has to stay a single file.

## 8. Verify
- it renders with no console errors;
- it looks like the preview on the VibeUI page you copied this from;
- if it does not, you changed something listed in section 5 — put it back.

For developers

npx shadcn@latest add https://vibeui.ru/r/table-014.json
https://vibeui.ru/r/table-014.json

Компонент самодостаточен: один файл, без зависимостей, палитра в переменных --vibeui-table-014-*. Серверный: хуков нет. Верхние ячейки шапки объявлены как th scope="colgroup" с colSpan — без colgroup скринридер не свяжет число с годом, и две колонки «План» станут неразличимы. Первая ячейка получает rowSpan={2} и остаётся th scope="col". Плоский список колонок собирается из групп через flatMap, поэтому тело таблицы рисуется одним проходом и порядок ячеек не может разойтись с шапкой. Граница между группами рисуется data-edge, а не nth-child: правило переживает добавление колонки.

Component source

The same file your agent installs. Here in case you would rather copy it by hand.

import type { ComponentPropsWithoutRef, CSSProperties } from "react"

export type Table014Group = {
  title: string
  columns: string[]
}

export type Table014Row = {
  title: string
  values: string[]
}

export type Table014Props = Omit<
  ComponentPropsWithoutRef<"div">,
  "children"
> & {
  groups?: Table014Group[]
  rows?: Table014Row[]
  /** Подпись первой колонки: она склеена на обе строки шапки. */
  leadTitle?: string
  caption?: string
  accent?: string
}

// Идея компонента: двухэтажная шапка. Верхний ряд — год со scope="colgroup"
// и colspan, нижний — колонки внутри года, а первая ячейка склеена rowspan
// на оба ряда. Без scope такая шапка для скринридера рассыпается: числа
// теряют год, к которому относятся.
const STYLES = `
:where([data-vibeui-block="table-014"]){
--vibeui-table-014-bg:oklch(1 0 0);
--vibeui-table-014-fg:oklch(0.24 0.014 265);
--vibeui-table-014-muted:oklch(0.56 0.014 265);
--vibeui-table-014-border:oklch(0.92 0.006 265);
--vibeui-table-014-head:oklch(0.975 0.003 265);
--vibeui-table-014-accent:oklch(0.55 0.2 262);
--vibeui-table-014-font:ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;
}
[data-vibeui-block="table-014"]{
width:100%;box-sizing:border-box;
font-family:var(--vibeui-table-014-font);color:var(--vibeui-table-014-fg);
}
[data-vibeui-block="table-014"] [data-part="shell"]{
background:var(--vibeui-table-014-bg);
border:1px solid var(--vibeui-table-014-border);border-radius:1rem;overflow:hidden;
}
[data-vibeui-block="table-014"] [data-part="scroll"]{overflow-x:auto}
[data-vibeui-block="table-014"] [data-part="scroll"]:focus-visible{
outline:2px solid var(--vibeui-table-014-accent);outline-offset:-2px;
}
[data-vibeui-block="table-014"] table{width:100%;border-collapse:collapse;font-size:0.8125rem;min-width:30rem}
[data-vibeui-block="table-014"] caption{
padding:0.875rem 1rem 0.625rem;text-align:left;font-size:0.9375rem;font-weight:650;
}
[data-vibeui-block="table-014"] th,
[data-vibeui-block="table-014"] td{padding:0.5rem 0.875rem;text-align:right;white-space:nowrap}
[data-vibeui-block="table-014"] thead th{background:var(--vibeui-table-014-head);font-weight:600}
/* Верхний этаж шапки центрируется над своей группой, нижний — по числам. */
[data-vibeui-block="table-014"] [data-part="group"]{
text-align:center;border-bottom:1px solid var(--vibeui-table-014-border);
letter-spacing:0.02em;
}
[data-vibeui-block="table-014"] [data-part="lead"]{text-align:left;vertical-align:bottom}
[data-vibeui-block="table-014"] tbody th{
text-align:left;font-weight:500;
border-top:1px solid var(--vibeui-table-014-border);
}
[data-vibeui-block="table-014"] tbody td{
border-top:1px solid var(--vibeui-table-014-border);
font-variant-numeric:tabular-nums;
}
/* Граница между годами: без неё два одинаковых столбца «План» сливаются. */
[data-vibeui-block="table-014"] [data-edge="group"]{
border-left:1px solid var(--vibeui-table-014-border);
}
[data-vibeui-block="table-014"] tbody tr:nth-child(even) th,
[data-vibeui-block="table-014"] tbody tr:nth-child(even) td{
background:color-mix(in oklab,var(--vibeui-table-014-head) 60%,transparent);
}
@media (prefers-reduced-motion:reduce){[data-vibeui-block="table-014"] *{animation:none!important;transition:none!important}}
`

const DEFAULT_GROUPS: Table014Group[] = [
  { title: "2024", columns: ["План", "Факт"] },
  { title: "2025", columns: ["План", "Факт"] },
]

const DEFAULT_ROWS: Table014Row[] = [
  { title: "Северо-Запад", values: ["12,0", "11,4", "13,5", "14,2"] },
  { title: "Центр", values: ["28,4", "30,1", "32,0", "31,6"] },
  { title: "Урал", values: ["9,8", "9,1", "10,4", "10,9"] },
  { title: "Сибирь", values: ["7,2", "7,7", "8,1", "8,0"] },
]

/**
 * Таблица с двухэтажной шапкой: группы колонок склеены colspan и scope.
 * Один файл, ноль зависимостей, собственная палитра.
 */
export function Table014({
  groups = DEFAULT_GROUPS,
  rows = DEFAULT_ROWS,
  leadTitle = "Регион",
  caption = "Выручка по регионам, млн ₽",
  accent,
  className,
  style,
  ...props
}: Table014Props) {
  const palette = {
    ...(accent ? { "--vibeui-table-014-accent": accent } : null),
    ...style,
  } as CSSProperties

  const columns = groups.flatMap((group, groupIndex) =>
    group.columns.map((column, columnIndex) => ({
      key: `${group.title}-${column}`,
      title: column,
      group: group.title,
      first: groupIndex > 0 && columnIndex === 0,
    })),
  )

  return (
    <>
      <style href="vibeui-table-014" precedence="medium">
        {STYLES}
      </style>
      <div
        {...props}
        data-vibeui-block="table-014"
        className={className}
        style={palette}
      >
        <div data-part="shell">
          <div
            data-part="scroll"
            role="region"
            aria-label={caption}
            tabIndex={0}
          >
            <table>
              <caption>{caption}</caption>
              <thead>
                <tr>
                  <th scope="col" rowSpan={2} data-part="lead">
                    {leadTitle}
                  </th>
                  {groups.map((group, index) => (
                    <th
                      key={group.title}
                      scope="colgroup"
                      colSpan={group.columns.length}
                      data-part="group"
                      data-edge={index > 0 ? "group" : undefined}
                    >
                      {group.title}
                    </th>
                  ))}
                </tr>
                <tr>
                  {columns.map((column) => (
                    <th
                      key={column.key}
                      scope="col"
                      data-edge={column.first ? "group" : undefined}
                    >
                      {column.title}
                    </th>
                  ))}
                </tr>
              </thead>
              <tbody>
                {rows.map((row) => (
                  <tr key={row.title}>
                    <th scope="row">{row.title}</th>
                    {columns.map((column, index) => (
                      <td
                        key={column.key}
                        data-edge={column.first ? "group" : undefined}
                      >
                        {row.values[index] ?? "—"}
                      </td>
                    ))}
                  </tr>
                ))}
              </tbody>
            </table>
          </div>
        </div>
      </div>
    </>
  )
}