Data Grid

Pinned Totals

A long grid that scrolls inside its own frame: the header sticks to the top and the totals row to the bottom, so the sum stays visible mid-list.

  • datagrid
  • sticky-header
  • totals
  • table

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/datagrid-004?lang=en

Операции

14 записей

Cash movements, March
КодОперацияСтатьяСумма
OP-3041Оплата хостингаОсновной-18 400
OP-3042Поступление от АтласОсновной96 000
OP-3043Реклама, мартМаркетинг-54 300
OP-3044Поступление от ВеткаОсновной72 000
OP-3045АрендаОсновной-120 000
OP-3046Возврат ДельтаОсновной-7 500
OP-3047Поступление от ГранатОсновной310 000
OP-3048Подписка на аналитикуПродукт-12 800
OP-3049Командировка, КазаньПродажи-31 200
OP-3050Поступление от ЁлкаОсновной48 000
OP-3051ОборудованиеПродукт-88 000
OP-3052Поступление от БерегОсновной12 500
OP-3053Юридические услугиОсновной-26 000
OP-3054Партнёрская выплатаМаркетинг-14 700
Итог за период165 600
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 "datagrid-004" (Pinned Totals) 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/datagrid-004.json

Registry item: https://vibeui.ru/r/datagrid-004.json
Installs to: components/vibeui/datagrid-004.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 long grid that scrolls inside its own frame: the header sticks to the top and the totals row to the bottom, so the sum stays visible mid-list.

A grid with a pinned header and a pinned totals row under vertical scrolling. Server component, zero dependencies, one file.

## 3. How to use it
import { Datagrid004 } from "@/components/vibeui/datagrid-004"

<Datagrid004 caption="Cash movements, March" height={20} />

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-datagrid-004-* palette — do not swap it for your theme tokens
- the background on the sticky header and sticky totals: transparent cells let rows show through
- the viewport height in rem rather than px — it must grow with the reader's font size
- tabindex=0 and aria-label on the scroll container: a vertical list is paged from the keyboard too
- the minus sign on outgoings, not just the color: color alone fails for color blindness
- tabular-nums in the amount column, otherwise the digits jitter while scrolling

## 6. You may change
- the line under the toolbar through caption
- the scroll viewport height through height, in rem
- the operations and amounts through rows
- the accent color through accent

## 7. Rules
- A sticky tfoot only works inside a scrolling container: without max-height it drifts to the page bottom.
- With three rows the viewport looks empty — lower height or drop the scrolling entirely.
- The total counts every row, not the visible ones: add a filter and you must recompute it yourself.
- 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/datagrid-004.json
https://vibeui.ru/r/datagrid-004.json

Компонент самодостаточен: один файл, без зависимостей, палитра в --vibeui-datagrid-004-*. Серверный: состояния нет. Высота окна прокрутки приходит числом в rem и попадает в переменную --vibeui-datagrid-004-height, поэтому окно растёт вместе со шрифтом пользователя. Шапка и tfoot закреплены position:sticky с собственным фоном; таблица собрана с border-collapse:separate, иначе липкие ячейки теряют границы. Контейнер прокрутки помечен role=region, aria-label и tabindex=0 — вертикальный список тоже листают с клавиатуры. Суммы выведены tabular-nums, расход помечен знаком минуса и цветом одновременно.

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 Datagrid004Row = {
  code: string
  operation: string
  account: string
  amount: number
}

export type Datagrid004Props = Omit<
  ComponentPropsWithoutRef<"section">,
  "children"
> & {
  rows?: Datagrid004Row[]
  caption?: string
  height?: number
  accent?: string
}

// Идея компонента: длинный список, у которого при вертикальной прокрутке
// на месте остаются и шапка, и строка итога. Итог внизу — это не украшение:
// на середине списка сумма нужна чаще, чем в конце. Высота окна прокрутки
// задаётся числом в rem, а не пикселями, поэтому окно растёт вместе со
// шрифтом пользователя.
const STYLES = `
:where([data-vibeui-block="datagrid-004"]){
--vibeui-datagrid-004-bg:oklch(1 0 0);
--vibeui-datagrid-004-fg:oklch(0.23 0.012 160);
--vibeui-datagrid-004-muted:oklch(0.54 0.012 160);
--vibeui-datagrid-004-border:oklch(0.92 0.006 160);
--vibeui-datagrid-004-head:oklch(0.975 0.004 160);
--vibeui-datagrid-004-zebra:oklch(0.985 0.003 160);
--vibeui-datagrid-004-accent:oklch(0.5 0.13 162);
--vibeui-datagrid-004-negative:oklch(0.55 0.19 25);
--vibeui-datagrid-004-height:16rem;
--vibeui-datagrid-004-font:ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;
}
[data-vibeui-block="datagrid-004"]{
box-sizing:border-box;width:100%;max-width:46rem;margin:0 auto;
background:var(--vibeui-datagrid-004-bg);color:var(--vibeui-datagrid-004-fg);
border:1px solid var(--vibeui-datagrid-004-border);border-radius:0.875rem;
font-family:var(--vibeui-datagrid-004-font);overflow:hidden;
}
[data-vibeui-block="datagrid-004"] *{box-sizing:border-box}
[data-vibeui-block="datagrid-004"] [data-part="head"]{
display:flex;flex-wrap:wrap;align-items:baseline;gap:0.5rem;padding:0.875rem;
}
[data-vibeui-block="datagrid-004"] [data-part="title"]{margin:0;font-size:0.875rem;font-weight:650}
[data-vibeui-block="datagrid-004"] [data-part="hint"]{
margin:0 0 0 auto;font-size:0.75rem;color:var(--vibeui-datagrid-004-muted);
}
[data-vibeui-block="datagrid-004"] [data-part="scroll"]{
max-height:var(--vibeui-datagrid-004-height);overflow:auto;
border-top:1px solid var(--vibeui-datagrid-004-border);
}
[data-vibeui-block="datagrid-004"] [data-part="scroll"]:focus-visible{outline:2px solid var(--vibeui-datagrid-004-accent);outline-offset:-2px}
[data-vibeui-block="datagrid-004"] table{
width:100%;border-collapse:separate;border-spacing:0;font-size:0.8125rem;
}
[data-vibeui-block="datagrid-004"] caption{
padding:0.625rem 0.875rem;text-align:left;
font-size:0.75rem;color:var(--vibeui-datagrid-004-muted);
background:var(--vibeui-datagrid-004-bg);
}
[data-vibeui-block="datagrid-004"] th,
[data-vibeui-block="datagrid-004"] td{
padding:0.4375rem 0.875rem;text-align:left;white-space:nowrap;
border-bottom:1px solid var(--vibeui-datagrid-004-border);
}
/* Липкая шапка: собственный фон обязателен, иначе строки просвечивают. */
[data-vibeui-block="datagrid-004"] thead th{
position:sticky;top:0;z-index:2;font-weight:600;
background:var(--vibeui-datagrid-004-head);
}
/* Липкий итог снизу — вторая опора взгляда при прокрутке середины. */
[data-vibeui-block="datagrid-004"] tfoot td{
position:sticky;bottom:0;z-index:2;font-weight:650;
background:var(--vibeui-datagrid-004-head);
border-top:1px solid var(--vibeui-datagrid-004-border);border-bottom:0;
}
[data-vibeui-block="datagrid-004"] tbody tr:nth-child(even) td,
[data-vibeui-block="datagrid-004"] tbody tr:nth-child(even) th{background:var(--vibeui-datagrid-004-zebra)}
[data-vibeui-block="datagrid-004"] [data-align="end"]{text-align:right;font-variant-numeric:tabular-nums}
[data-vibeui-block="datagrid-004"] [data-part="code"]{
font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;
font-size:0.75rem;color:var(--vibeui-datagrid-004-muted);font-weight:500;
}
[data-vibeui-block="datagrid-004"] [data-part="account"]{color:var(--vibeui-datagrid-004-muted)}
[data-vibeui-block="datagrid-004"] [data-sign="minus"]{color:var(--vibeui-datagrid-004-negative)}
@media (prefers-reduced-motion:reduce){[data-vibeui-block="datagrid-004"] *{animation:none!important;transition:none!important}}
`

const DEFAULT_ROWS: Datagrid004Row[] = [
  {
    code: "OP-3041",
    operation: "Оплата хостинга",
    account: "Основной",
    amount: -18400,
  },
  {
    code: "OP-3042",
    operation: "Поступление от Атлас",
    account: "Основной",
    amount: 96000,
  },
  {
    code: "OP-3043",
    operation: "Реклама, март",
    account: "Маркетинг",
    amount: -54300,
  },
  {
    code: "OP-3044",
    operation: "Поступление от Ветка",
    account: "Основной",
    amount: 72000,
  },
  {
    code: "OP-3045",
    operation: "Аренда",
    account: "Основной",
    amount: -120000,
  },
  {
    code: "OP-3046",
    operation: "Возврат Дельта",
    account: "Основной",
    amount: -7500,
  },
  {
    code: "OP-3047",
    operation: "Поступление от Гранат",
    account: "Основной",
    amount: 310000,
  },
  {
    code: "OP-3048",
    operation: "Подписка на аналитику",
    account: "Продукт",
    amount: -12800,
  },
  {
    code: "OP-3049",
    operation: "Командировка, Казань",
    account: "Продажи",
    amount: -31200,
  },
  {
    code: "OP-3050",
    operation: "Поступление от Ёлка",
    account: "Основной",
    amount: 48000,
  },
  {
    code: "OP-3051",
    operation: "Оборудование",
    account: "Продукт",
    amount: -88000,
  },
  {
    code: "OP-3052",
    operation: "Поступление от Берег",
    account: "Основной",
    amount: 12500,
  },
  {
    code: "OP-3053",
    operation: "Юридические услуги",
    account: "Основной",
    amount: -26000,
  },
  {
    code: "OP-3054",
    operation: "Партнёрская выплата",
    account: "Маркетинг",
    amount: -14700,
  },
]

/**
 * Сетка с закреплённой шапкой и закреплённой строкой итога при
 * вертикальной прокрутке. Серверный компонент, один файл.
 */
export function Datagrid004({
  rows = DEFAULT_ROWS,
  caption = "Движение по счетам за март",
  height = 16,
  accent,
  className,
  style,
  ...props
}: Datagrid004Props) {
  const total = rows.reduce((sum, row) => sum + row.amount, 0)

  const palette = {
    "--vibeui-datagrid-004-height": `${height}rem`,
    ...(accent ? { "--vibeui-datagrid-004-accent": accent } : null),
    ...style,
  } as CSSProperties

  return (
    <>
      <style href="vibeui-datagrid-004" precedence="medium">
        {STYLES}
      </style>
      <section
        {...props}
        data-vibeui-block="datagrid-004"
        className={className}
        style={palette}
      >
        <div data-part="head">
          <h3 data-part="title">Операции</h3>
          <p data-part="hint">{rows.length} записей</p>
        </div>
        <div
          data-part="scroll"
          role="region"
          aria-label="Список операций, прокручивается вертикально"
          tabIndex={0}
        >
          <table>
            <caption>{caption}</caption>
            <thead>
              <tr>
                <th scope="col">Код</th>
                <th scope="col">Операция</th>
                <th scope="col">Статья</th>
                <th scope="col" data-align="end">
                  Сумма
                </th>
              </tr>
            </thead>
            <tbody>
              {rows.map((row) => (
                <tr key={row.code}>
                  <th scope="row" data-part="code">
                    {row.code}
                  </th>
                  <td>{row.operation}</td>
                  <td data-part="account">{row.account}</td>
                  <td
                    data-align="end"
                    data-sign={row.amount < 0 ? "minus" : "plus"}
                  >
                    {row.amount.toLocaleString("ru-RU")} ₽
                  </td>
                </tr>
              ))}
            </tbody>
            <tfoot>
              <tr>
                <td colSpan={3}>Итог за период</td>
                <td data-align="end" data-sign={total < 0 ? "minus" : "plus"}>
                  {total.toLocaleString("ru-RU")} ₽
                </td>
              </tr>
            </tfoot>
          </table>
        </div>
      </section>
    </>
  )
}