Tables

Aligned Numerals

A numeric table where decimal separators line up: every cell is a grid of sign, integer part and fraction, and the minus hangs to the left without shifting the digits.

  • table
  • numbers
  • alignment
  • 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-016?lang=en

Cash movement this month, k
СтатьяПланФактОтклонение
Подписки1 240,501 310,2569,75
Разовые продажи420,00388,4031,60
Партнёрские выплаты96,30128,7532,45
Возвраты18,007,5010,50
Прочее64,2064,200,00
Итого1 610,401 626,6016,20
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-016" (Aligned Numerals) 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-016.json

Registry item: https://vibeui.ru/r/table-016.json
Installs to: components/vibeui/table-016.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 numeric table where decimal separators line up: every cell is a grid of sign, integer part and fraction, and the minus hangs to the left without shifting the digits.

A numeric table with decimal alignment, a hanging minus and a totals row. Zero dependencies, one file, no client JS.

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

<Table016
  caption="Cash movement this month, k"
  decimals={2}
/>

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-016-* palette — do not swap it for your theme tokens
- splitting a number into three grid cells: without it the separators drift and the column cannot be scanned
- the "−" sign instead of a hyphen: a hyphen is narrower and is announced as a dash
- font-variant-numeric:tabular-nums: proportional digits break any decimal alignment
- that negativity is carried by the sign, not the colour: a red column is indistinguishable to colour-blind readers
- the component's own light surface: dark text disappears on the dark catalog card

## 6. You may change
- the column titles through columns
- the line items and their numbers through rows
- the number of fractional digits through decimals
- the table caption through caption

## 7. Rules
- Numbers are stored as numbers and formatted at render: a pre-formatted string cannot be aligned.
- The sign column width is set in ch units — re-check it after a font change.
- The total is a plain column sum: weighted averages cannot be plugged in here.
- 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-016.json
https://vibeui.ru/r/table-016.json

Компонент самодостаточен: один файл, без зависимостей, палитра в переменных --vibeui-table-016-*. Серверный: суммы считаются при рендере, состояния нет. Число разбивается функцией split() на знак, целую и дробную часть, а разметка кладёт их в grid из трёх колонок 0.75ch / 1fr / auto — границы колонок общие для всех строк, поэтому запятые выстраиваются, а висящий минус не сдвигает разряды. Минус — типографский знак «−» (U+2212), а не дефис: он и шире, и читается как минус. Отрицательное значение подкрашено, но смысл несёт сам знак, а не цвет. Разряды разделены узким неразрывным пробелом, шрифт — 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 Table016Row = {
  title: string
  values: number[]
}

export type Table016Props = Omit<
  ComponentPropsWithoutRef<"div">,
  "children"
> & {
  columns?: string[]
  rows?: Table016Row[]
  /** Сколько знаков после запятой печатать во всех ячейках. */
  decimals?: number
  caption?: string
  accent?: string
}

// Идея компонента: числа выстроены по разряду. Каждая ячейка — маленькая
// сетка из трёх колонок: знак, целая часть, дробная. Целые части упираются
// в одну границу, запятые стоят в одну линию, а минус висит слева и не
// сдвигает цифры. Минус — знак «−», а не дефис, и он читается вслух:
// цветом отрицательное значение не размечают.
const STYLES = `
:where([data-vibeui-block="table-016"]){
--vibeui-table-016-bg:oklch(1 0 0);
--vibeui-table-016-fg:oklch(0.24 0.014 265);
--vibeui-table-016-muted:oklch(0.56 0.014 265);
--vibeui-table-016-border:oklch(0.92 0.006 265);
--vibeui-table-016-head:oklch(0.975 0.003 265);
--vibeui-table-016-accent:oklch(0.55 0.2 262);
--vibeui-table-016-minus:oklch(0.53 0.19 27);
--vibeui-table-016-font:ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;
}
[data-vibeui-block="table-016"]{
width:100%;box-sizing:border-box;
font-family:var(--vibeui-table-016-font);color:var(--vibeui-table-016-fg);
}
[data-vibeui-block="table-016"] [data-part="shell"]{
background:var(--vibeui-table-016-bg);
border:1px solid var(--vibeui-table-016-border);border-radius:1rem;overflow:hidden;
}
[data-vibeui-block="table-016"] [data-part="scroll"]{overflow-x:auto}
[data-vibeui-block="table-016"] [data-part="scroll"]:focus-visible{
outline:2px solid var(--vibeui-table-016-accent);outline-offset:-2px;
}
[data-vibeui-block="table-016"] table{width:100%;border-collapse:collapse;font-size:0.8125rem;min-width:26rem}
[data-vibeui-block="table-016"] caption{
padding:0.875rem 1rem 0.5rem;text-align:left;font-size:0.9375rem;font-weight:650;
}
[data-vibeui-block="table-016"] th,
[data-vibeui-block="table-016"] td{
padding:0.4375rem 0.875rem;text-align:left;
border-top:1px solid var(--vibeui-table-016-border);
}
[data-vibeui-block="table-016"] thead th{
background:var(--vibeui-table-016-head);font-weight:600;white-space:nowrap;text-align:right;
}
[data-vibeui-block="table-016"] thead th:first-child{text-align:left}
[data-vibeui-block="table-016"] tbody th,
[data-vibeui-block="table-016"] tfoot th{font-weight:500;white-space:nowrap}
[data-vibeui-block="table-016"] tfoot th,
[data-vibeui-block="table-016"] tfoot td{font-weight:700;background:var(--vibeui-table-016-head)}
/* Ячейка числа: знак | целая часть | дробная. Границы колонок общие для
   всех строк, поэтому запятые встают в одну вертикаль. */
[data-vibeui-block="table-016"] [data-part="num"]{
display:grid;grid-template-columns:0.75ch 1fr auto;align-items:baseline;
font-variant-numeric:tabular-nums;
}
[data-vibeui-block="table-016"] [data-part="sign"]{text-align:left}
[data-vibeui-block="table-016"] [data-part="whole"]{text-align:right}
[data-vibeui-block="table-016"] [data-part="fraction"]{text-align:left}
[data-vibeui-block="table-016"] [data-sign="neg"]{color:var(--vibeui-table-016-minus)}
@media (prefers-reduced-motion:reduce){[data-vibeui-block="table-016"] *{animation:none!important;transition:none!important}}
`

const DEFAULT_COLUMNS = ["План", "Факт", "Отклонение"]

const DEFAULT_ROWS: Table016Row[] = [
  { title: "Подписки", values: [1240.5, 1310.25, 69.75] },
  { title: "Разовые продажи", values: [420, 388.4, -31.6] },
  { title: "Партнёрские выплаты", values: [-96.3, -128.75, -32.45] },
  { title: "Возвраты", values: [-18, -7.5, 10.5] },
  { title: "Прочее", values: [64.2, 64.2, 0] },
]

const MINUS = "−"

/** Разбиваем число на знак, целую и дробную часть: их печатает разметка. */
function split(value: number, decimals: number) {
  const fixed = Math.abs(value).toFixed(decimals)
  const [whole, fraction = ""] = fixed.split(".")

  return {
    sign: value < 0 ? MINUS : "",
    whole: whole.replace(/\B(?=(\d{3})+(?!\d))/g, " "),
    fraction: fraction ? `,${fraction}` : "",
  }
}

/**
 * Числовая таблица с выравниванием по разряду и висящим минусом.
 * Один файл, ноль зависимостей, собственная палитра.
 */
export function Table016({
  columns = DEFAULT_COLUMNS,
  rows = DEFAULT_ROWS,
  decimals = 2,
  caption = "Движение денег за месяц, тыс. ₽",
  accent,
  className,
  style,
  ...props
}: Table016Props) {
  const palette = {
    ...(accent ? { "--vibeui-table-016-accent": accent } : null),
    ...style,
  } as CSSProperties

  const totals = columns.map((_, index) =>
    rows.reduce((sum, row) => sum + (row.values[index] ?? 0), 0),
  )

  const renderNumber = (value: number) => {
    const parts = split(value, decimals)

    return (
      <span data-part="num" data-sign={value < 0 ? "neg" : undefined}>
        <span data-part="sign">{parts.sign}</span>
        <span data-part="whole">{parts.whole}</span>
        <span data-part="fraction">{parts.fraction}</span>
      </span>
    )
  }

  return (
    <>
      <style href="vibeui-table-016" precedence="medium">
        {STYLES}
      </style>
      <div
        {...props}
        data-vibeui-block="table-016"
        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>
                  {columns.map((column) => (
                    <th key={column} scope="col">
                      {column}
                    </th>
                  ))}
                </tr>
              </thead>
              <tbody>
                {rows.map((row) => (
                  <tr key={row.title}>
                    <th scope="row">{row.title}</th>
                    {columns.map((column, index) => (
                      <td key={column}>
                        {renderNumber(row.values[index] ?? 0)}
                      </td>
                    ))}
                  </tr>
                ))}
              </tbody>
              <tfoot>
                <tr>
                  <th scope="row">Итого</th>
                  {totals.map((total, index) => (
                    <td key={columns[index]}>{renderNumber(total)}</td>
                  ))}
                </tr>
              </tfoot>
            </table>
          </div>
        </div>
      </div>
    </>
  )
}