Tables

Quote Totals

A quote with the arithmetic left visible: line items on top, and subtotal, tax and amount due as separate rows in tfoot instead of one collapsed number.

  • table
  • invoice
  • totals
  • finance

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-012?lang=en

Website launch quote
РаботаКол-воЦенаСумма
Дизайн лендинга5 экранов, 2 правки184 000,00 ₽84 000,00 ₽
Вёрсткаадаптив до 360 px146 000,00 ₽46 000,00 ₽
Иконкиштучно12900,00 ₽10 800,00 ₽
Поддержкачасов в месяц83 200,00 ₽25 600,00 ₽
Подытог166 400,00 ₽
Налог 20%33 280,00 ₽
К оплате199 680,00 ₽
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-012" (Quote 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/table-012.json

Registry item: https://vibeui.ru/r/table-012.json
Installs to: components/vibeui/table-012.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 quote with the arithmetic left visible: line items on top, and subtotal, tax and amount due as separate rows in tfoot instead of one collapsed number.

A quote: line items, subtotal, tax at a given rate and the amount due. Totals are computed at render. Zero dependencies, one file, no client JS.

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

<Table012
  caption="Website launch quote"
  taxRate={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-table-012-* palette — do not swap it for your theme tokens
- tfoot for the arithmetic: browsers and print know it is a total, not another line item
- th scope="row" on the calculation labels: a bare "1,200" is useless when announced
- tabular-nums and nowrap in numeric columns: without them digits dance and sums break across lines
- the local money() helper instead of Intl: server and browser locales differ and break hydration
- the component's own light surface: dark text disappears on the dark catalog card

## 6. You may change
- the work items, quantities and prices through lines
- the tax rate through taxRate
- the currency sign through currency
- the quote caption through caption

## 7. Rules
- Tax is applied on top of the subtotal; if prices already include it, set the rate to zero.
- Rounding happens per line, so the total can differ from external bookkeeping by a cent.
- Discounts are not computed: add them as a separate line with a negative price.
- 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-012.json
https://vibeui.ru/r/table-012.json

Компонент самодостаточен: один файл, без зависимостей, палитра в переменных --vibeui-table-012-*. Серверный: считает подытог и налог прямо при рендере, состояния нет. Позиции лежат в tbody, расчёт — в tfoot, и каждая его подпись оформлена как th scope="row" с colSpan, поэтому число всегда озвучивается вместе со словом. Суммы печатает своя функция money(): разряды разделены узким неразрывным пробелом, а не Intl, потому что вывод Intl зависит от локали среды и различался бы между сервером и клиентом. Числовые колонки получают font-variant-numeric:tabular-nums и white-space:nowrap: разряды выстраиваются, сумма не рвётся переносом.

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 Table012Line = {
  title: string
  note?: string
  quantity: number
  price: number
}

export type Table012Props = Omit<
  ComponentPropsWithoutRef<"div">,
  "children"
> & {
  lines?: Table012Line[]
  /** Ставка налога в процентах: считается поверх подытога. */
  taxRate?: number
  currency?: string
  caption?: string
  accent?: string
}

// Идея компонента: смета, где расчёт виден целиком — подытог, скидка, налог
// и итог лежат в tfoot отдельными строками, а не сворачиваются в одно число.
// Подписи расчёта — заголовки строк (th scope="row"), поэтому «1 200 ₽»
// всегда читается вместе со словом, к которому относится.
const STYLES = `
:where([data-vibeui-block="table-012"]){
--vibeui-table-012-bg:oklch(1 0 0);
--vibeui-table-012-fg:oklch(0.24 0.014 265);
--vibeui-table-012-muted:oklch(0.56 0.014 265);
--vibeui-table-012-border:oklch(0.92 0.006 265);
--vibeui-table-012-head:oklch(0.975 0.003 265);
--vibeui-table-012-accent:oklch(0.55 0.2 262);
--vibeui-table-012-font:ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;
}
[data-vibeui-block="table-012"]{
width:100%;box-sizing:border-box;
font-family:var(--vibeui-table-012-font);color:var(--vibeui-table-012-fg);
}
[data-vibeui-block="table-012"] [data-part="shell"]{
background:var(--vibeui-table-012-bg);
border:1px solid var(--vibeui-table-012-border);border-radius:1rem;overflow:hidden;
}
[data-vibeui-block="table-012"] [data-part="scroll"]{overflow-x:auto}
[data-vibeui-block="table-012"] [data-part="scroll"]:focus-visible{
outline:2px solid var(--vibeui-table-012-accent);outline-offset:-2px;
}
[data-vibeui-block="table-012"] table{width:100%;border-collapse:collapse;font-size:0.8125rem;min-width:24rem}
[data-vibeui-block="table-012"] caption{
padding:0.875rem 1rem 0.5rem;text-align:left;font-size:0.9375rem;font-weight:650;
}
[data-vibeui-block="table-012"] th,
[data-vibeui-block="table-012"] td{padding:0.5rem 0.875rem;text-align:left}
[data-vibeui-block="table-012"] thead th{
background:var(--vibeui-table-012-head);font-weight:600;white-space:nowrap;
border-bottom:1px solid var(--vibeui-table-012-border);
}
[data-vibeui-block="table-012"] tbody tr + tr th,
[data-vibeui-block="table-012"] tbody tr + tr td{border-top:1px solid var(--vibeui-table-012-border)}
[data-vibeui-block="table-012"] [data-align="end"]{
text-align:right;font-variant-numeric:tabular-nums;white-space:nowrap;
}
[data-vibeui-block="table-012"] [data-part="note"]{
display:block;font-size:0.75rem;font-weight:400;color:var(--vibeui-table-012-muted);
}
[data-vibeui-block="table-012"] tbody th{font-weight:600}
/* Расчёт отбит от позиций: без этой линии итог читается как ещё одна позиция. */
[data-vibeui-block="table-012"] tfoot th,
[data-vibeui-block="table-012"] tfoot td{
padding:0.375rem 0.875rem;color:var(--vibeui-table-012-muted);font-weight:500;
}
[data-vibeui-block="table-012"] tfoot tr:first-child th,
[data-vibeui-block="table-012"] tfoot tr:first-child td{
padding-top:0.75rem;border-top:1px solid var(--vibeui-table-012-border);
}
[data-vibeui-block="table-012"] tfoot [data-row="total"] th,
[data-vibeui-block="table-012"] tfoot [data-row="total"] td{
padding-top:0.5rem;padding-bottom:0.875rem;
color:var(--vibeui-table-012-fg);font-size:1rem;font-weight:700;
}
[data-vibeui-block="table-012"] tfoot [data-row="total"] td{color:var(--vibeui-table-012-accent)}
@media (prefers-reduced-motion:reduce){[data-vibeui-block="table-012"] *{animation:none!important;transition:none!important}}
`

const DEFAULT_LINES: Table012Line[] = [
  {
    title: "Дизайн лендинга",
    note: "5 экранов, 2 правки",
    quantity: 1,
    price: 84000,
  },
  { title: "Вёрстка", note: "адаптив до 360 px", quantity: 1, price: 46000 },
  { title: "Иконки", note: "штучно", quantity: 12, price: 900 },
  { title: "Поддержка", note: "часов в месяц", quantity: 8, price: 3200 },
]

/** Разряды разделяем неразрывным пробелом: сумма не должна рваться переносом. */
function money(value: number, currency: string) {
  const fixed = Math.round(value * 100) / 100
  const [whole, fraction = "00"] = fixed.toFixed(2).split(".")
  const grouped = whole.replace(/\B(?=(\d{3})+(?!\d))/g, " ")

  return `${grouped},${fraction} ${currency}`
}

/**
 * Смета с раскрытым расчётом: подытог, скидка, налог и итог в tfoot.
 * Один файл, ноль зависимостей, собственная палитра.
 */
export function Table012({
  lines = DEFAULT_LINES,
  taxRate = 20,
  currency = "₽",
  caption = "Смета на запуск сайта",
  accent,
  className,
  style,
  ...props
}: Table012Props) {
  const palette = {
    ...(accent ? { "--vibeui-table-012-accent": accent } : null),
    ...style,
  } as CSSProperties

  const subtotal = lines.reduce(
    (sum, line) => sum + line.quantity * line.price,
    0,
  )
  const tax = (subtotal * taxRate) / 100

  return (
    <>
      <style href="vibeui-table-012" precedence="medium">
        {STYLES}
      </style>
      <div
        {...props}
        data-vibeui-block="table-012"
        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">Работа</th>
                  <th scope="col" data-align="end">
                    Кол-во
                  </th>
                  <th scope="col" data-align="end">
                    Цена
                  </th>
                  <th scope="col" data-align="end">
                    Сумма
                  </th>
                </tr>
              </thead>
              <tbody>
                {lines.map((line) => (
                  <tr key={line.title}>
                    <th scope="row">
                      {line.title}
                      {line.note ? (
                        <span data-part="note">{line.note}</span>
                      ) : null}
                    </th>
                    <td data-align="end">{line.quantity}</td>
                    <td data-align="end">{money(line.price, currency)}</td>
                    <td data-align="end">
                      {money(line.quantity * line.price, currency)}
                    </td>
                  </tr>
                ))}
              </tbody>
              <tfoot>
                <tr>
                  <th scope="row" colSpan={3} data-align="end">
                    Подытог
                  </th>
                  <td data-align="end">{money(subtotal, currency)}</td>
                </tr>
                <tr>
                  <th scope="row" colSpan={3} data-align="end">
                    Налог {taxRate}%
                  </th>
                  <td data-align="end">{money(tax, currency)}</td>
                </tr>
                <tr data-row="total">
                  <th scope="row" colSpan={3} data-align="end">
                    К оплате
                  </th>
                  <td data-align="end">{money(subtotal + tax, currency)}</td>
                </tr>
              </tfoot>
            </table>
          </div>
        </div>
      </div>
    </>
  )
}