Tables

Footnoted Table

A table with footnotes: the marker in a row is a real anchor link to the item below, the item links back, and the cell is tied to the explanation through aria-describedby.

  • table
  • footnote
  • anchor
  • accessibility

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

Показатели за III квартал
ПоказательЗначениеК прошлому
Активные команды1 284+12%
Платящие команды1417+6%
Средний чек24 100 ₽−2%
Отток за квартал33,4%−0,6 п. п.

Notes

  1. Учитываются команды хотя бы с одной оплатой за последние 90 дней.
  2. Без учёта разовых продлений и партнёрских скидок.
  3. Пункты процента, а не проценты: сравнение с прошлым кварталом.
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-015" (Footnoted Table) 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-015.json

Registry item: https://vibeui.ru/r/table-015.json
Installs to: components/vibeui/table-015.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 footnotes: the marker in a row is a real anchor link to the item below, the item links back, and the cell is tied to the explanation through aria-describedby.

A table with footnotes underneath: anchor links both ways and aria-describedby on the row. Zero dependencies, one file, no client JS.

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

<Table015
  caption="Third quarter metrics"
  idPrefix="q3"
/>

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-015-* palette — do not swap it for your theme tokens
- aria-describedby on the row header: without it the note is reachable only by following the link
- the back link inside the footnote: without a return the reader loses their place in the table
- building every id from idPrefix: two instances on a page would otherwise steal each other's anchors
- the visible focus and padding on the marker: a tiny sup is unclickable and unfocusable without them
- the component's own light surface: dark text disappears on the dark catalog card

## 6. You may change
- the rows and their values through rows
- the footnote texts through notes
- the footnote block heading through notesTitle
- the table caption through caption and the anchor prefix through idPrefix

## 7. Rules
- A row's note number is a one-based index: pointing at a missing item produces a dead anchor.
- More than three or four notes per table read poorly: move the rest into prose below it.
- The return arrow is announced through aria-label — the ↩ glyph itself says nothing.
- 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-015.json
https://vibeui.ru/r/table-015.json

Компонент самодостаточен: один файл, без зависимостей, палитра в переменных --vibeui-table-015-*. Серверный: хуков нет. Сноски лежат в ol под таблицей, у каждого пункта свой id, маркер в строке — ссылка на этот id со своим id для обратного перехода. Плюс к переходу заголовок строки несёт aria-describedby: скринридер прочитает пояснение сразу, не уводя пользователя из таблицы. Все id строятся от idPrefix, поэтому два экземпляра на одной странице не делят якоря. Маркер остаётся полноценной целью нажатия: у ссылки есть padding и видимый фокус, вопреки мелкому кеглю sup.

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 Table015Row = {
  title: string
  value: string
  share: string
  /** Номер сноски, считая с единицы: 0 или пропуск — без маркера. */
  note?: number
}

export type Table015Props = Omit<
  ComponentPropsWithoutRef<"div">,
  "children"
> & {
  rows?: Table015Row[]
  notes?: string[]
  notesTitle?: string
  caption?: string
  /** Префикс id: два экземпляра на странице не должны делить якоря сносок. */
  idPrefix?: string
  accent?: string
}

// Идея компонента: таблица со сносками, которые не теряются. Маркер в ячейке
// — настоящая ссылка на пункт списка под таблицей, у пункта есть обратная
// ссылка, а ячейка связана со сноской через aria-describedby: скринридер
// прочитает пояснение сразу, не уводя пользователя из строки.
const STYLES = `
:where([data-vibeui-block="table-015"]){
--vibeui-table-015-bg:oklch(1 0 0);
--vibeui-table-015-fg:oklch(0.24 0.014 265);
--vibeui-table-015-muted:oklch(0.56 0.014 265);
--vibeui-table-015-border:oklch(0.92 0.006 265);
--vibeui-table-015-head:oklch(0.975 0.003 265);
--vibeui-table-015-accent:oklch(0.55 0.2 262);
--vibeui-table-015-font:ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;
}
[data-vibeui-block="table-015"]{
width:100%;box-sizing:border-box;
font-family:var(--vibeui-table-015-font);color:var(--vibeui-table-015-fg);
}
[data-vibeui-block="table-015"] [data-part="shell"]{
background:var(--vibeui-table-015-bg);
border:1px solid var(--vibeui-table-015-border);border-radius:1rem;overflow:hidden;
}
[data-vibeui-block="table-015"] [data-part="scroll"]{overflow-x:auto}
[data-vibeui-block="table-015"] [data-part="scroll"]:focus-visible{
outline:2px solid var(--vibeui-table-015-accent);outline-offset:-2px;
}
[data-vibeui-block="table-015"] table{width:100%;border-collapse:collapse;font-size:0.8125rem;min-width:22rem}
[data-vibeui-block="table-015"] caption{
padding:0.875rem 1rem 0.5rem;text-align:left;font-size:0.9375rem;font-weight:650;
}
[data-vibeui-block="table-015"] th,
[data-vibeui-block="table-015"] td{
padding:0.5rem 0.875rem;text-align:left;
border-top:1px solid var(--vibeui-table-015-border);
}
[data-vibeui-block="table-015"] thead th{background:var(--vibeui-table-015-head);font-weight:600;white-space:nowrap}
[data-vibeui-block="table-015"] tbody th{font-weight:500}
[data-vibeui-block="table-015"] [data-align="end"]{text-align:right;font-variant-numeric:tabular-nums}
/* Маркер сноски мельче и выше строки, но остаётся полноценной целью нажатия. */
[data-vibeui-block="table-015"] [data-part="marker"]{
font-size:0.6875rem;line-height:0;
}
[data-vibeui-block="table-015"] [data-part="marker"] a{
color:var(--vibeui-table-015-accent);text-decoration:none;font-weight:650;
padding:0 0.125rem;border-radius:0.25rem;
}
[data-vibeui-block="table-015"] [data-part="marker"] a:hover{text-decoration:underline}
[data-vibeui-block="table-015"] a:focus-visible{
outline:2px solid var(--vibeui-table-015-accent);outline-offset:2px;
}
[data-vibeui-block="table-015"] [data-part="notes"]{
margin:0;padding:0.75rem 1rem 0.875rem 2.125rem;
border-top:1px solid var(--vibeui-table-015-border);
background:var(--vibeui-table-015-head);
font-size:0.75rem;line-height:1.5;color:var(--vibeui-table-015-muted);
}
[data-vibeui-block="table-015"] [data-part="notes-title"]{
margin:0;padding:0.75rem 1rem 0;
border-top:1px solid var(--vibeui-table-015-border);
background:var(--vibeui-table-015-head);
font-size:0.6875rem;font-weight:650;letter-spacing:0.06em;text-transform:uppercase;
color:var(--vibeui-table-015-muted);
}
[data-vibeui-block="table-015"] [data-part="notes-title"] + [data-part="notes"]{border-top:0;padding-top:0.375rem}
[data-vibeui-block="table-015"] [data-part="notes"] li{margin:0.25rem 0}
[data-vibeui-block="table-015"] [data-part="notes"] li:target{color:var(--vibeui-table-015-fg)}
[data-vibeui-block="table-015"] [data-part="back"]{
margin-left:0.375rem;color:var(--vibeui-table-015-accent);text-decoration:none;
}
@media (prefers-reduced-motion:reduce){[data-vibeui-block="table-015"] *{animation:none!important;transition:none!important}}
`

const DEFAULT_ROWS: Table015Row[] = [
  { title: "Активные команды", value: "1 284", share: "+12%" },
  { title: "Платящие команды", value: "417", share: "+6%", note: 1 },
  { title: "Средний чек", value: "4 100 ₽", share: "−2%", note: 2 },
  { title: "Отток за квартал", value: "3,4%", share: "−0,6 п. п.", note: 3 },
]

const DEFAULT_NOTES = [
  "Учитываются команды хотя бы с одной оплатой за последние 90 дней.",
  "Без учёта разовых продлений и партнёрских скидок.",
  "Пункты процента, а не проценты: сравнение с прошлым кварталом.",
]

/**
 * Таблица со сносками под ней: маркеры — якорные ссылки, у пунктов есть возврат.
 * Один файл, ноль зависимостей, собственная палитра.
 */
export function Table015({
  rows = DEFAULT_ROWS,
  notes = DEFAULT_NOTES,
  notesTitle = "Примечания",
  caption = "Показатели за III квартал",
  idPrefix = "table-015",
  accent,
  className,
  style,
  ...props
}: Table015Props) {
  const palette = {
    ...(accent ? { "--vibeui-table-015-accent": accent } : null),
    ...style,
  } as CSSProperties

  return (
    <>
      <style href="vibeui-table-015" precedence="medium">
        {STYLES}
      </style>
      <div
        {...props}
        data-vibeui-block="table-015"
        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>
                </tr>
              </thead>
              <tbody>
                {rows.map((row) => (
                  <tr key={row.title}>
                    <th
                      scope="row"
                      aria-describedby={
                        row.note ? `${idPrefix}-note-${row.note}` : undefined
                      }
                    >
                      {row.title}
                      {row.note ? (
                        <sup data-part="marker">
                          <a
                            id={`${idPrefix}-ref-${row.note}`}
                            href={`#${idPrefix}-note-${row.note}`}
                            aria-label={`Сноска ${row.note}`}
                          >
                            {row.note}
                          </a>
                        </sup>
                      ) : null}
                    </th>
                    <td data-align="end">{row.value}</td>
                    <td data-align="end">{row.share}</td>
                  </tr>
                ))}
              </tbody>
            </table>
          </div>
          <p data-part="notes-title">{notesTitle}</p>
          <ol data-part="notes">
            {notes.map((note, index) => (
              <li key={note} id={`${idPrefix}-note-${index + 1}`}>
                {note}
                <a
                  data-part="back"
                  href={`#${idPrefix}-ref-${index + 1}`}
                  aria-label={`Вернуться к строке со сноской ${index + 1}`}
                >
                  ↩
                </a>
              </li>
            ))}
          </ol>
        </div>
      </div>
    </>
  )
}