GrTable
A simple table for compact display of structured data.
Machine-translated from the Russian original, not yet reviewed. Read the original
When to take it
- the cells are styled by the consumer — markup of your own for
<tr>/<td>, while only the wrapper and the behaviour are needed from the component; - the table is wider than the screen — the scroll wrapper is reachable from the keyboard and is declared as a region;
- the heading has to stay visible —
stickyHeadertogether withmaxHeight; - the table already exists and is not being rewritten — the wrapper adds a
caption, states and scrolling without touching the markup.
When to take something else
| Need | Take |
|---|---|
| Sorting, row selection and cell slots are needed | GrDataTable |
| The rows are uniform and have no columns | GrList |
| The rows are nested | GrTree |
| The order of the rows is changed by the user | GrSortableList |
| The data is shown as a chart | GrChartBar |
caption is not an ornament: without it the table is nameless in the list of landmarks of a screen
reader, and on a page with three tables they cannot be told apart.
The scrolling is reachable from the keyboard
The overflow-x-auto container always stands in the tab order (tabindex="0"). That used to depend
on regionLabel: a wide table without a label could not be scrolled from the keyboard at all — a
direct violation of WCAG 2.1.1.
regionLabel still switches role="region" on and gives the area a name, but it has nothing to do
with the reachability of the scrolling.
The empty state and loading
<GrTable :loading="pending" :column-count="4">
<template #header>…</template>
<tr v-for="row in rows" :key="row.id">…</tr>
<template #empty>Nothing found</template>
</GrTable>
The table determines the emptiness itself — by the content of the slot. columnCount is needed by
the service row: without it the colspan will not stretch across the full width.
loading draws skeleton rows and marks the container aria-busy; the #loading slot replaces them
as a whole. Loading is stronger than emptiness — otherwise the table would blink the “nothing yet”
text on every request.
The imperative API
const table = ref<InstanceType<typeof GrTable>>()
table.value?.scrollToRow(42) // the index of a content row; false — there is no such row
table.value?.scrollTo({ top: 0 })
scrollToRow(index, options?) accepts the index of a row in the markup — the table does not render
the rows and does not know their keys, so the addressing is positional only. Content rows are
counted: service skeleton and empty rows are not addressed, and in the loading and “empty” states
the method returns false, as it does with an index out of range. In GrDataTable the method of
the same name works by the key of a row — on migration the argument changes its meaning.
Styling the rows
striped and hoverable are hung on the <tbody> as a whole rather than on every cell: the markup
of the rows is written by the consumer, and demanding classes from them would be odd.
The sticky heading uses a local z-[1] inside its own container — it has nothing to do with the
--gr-z-* scale, which is described in
../z-index.md.
An incomplete set of rows in the markup
Two props are needed when the rows do not render the whole set — with virtualisation in
GrDataTable, for instance:
rowCountis the full number of rows together with the heading ones, and it goes intoaria-rowcount. Without it a screen reader counts the rows by the markup and will announce “5 of 20” on a table of ten thousand; the rows meanwhile are obliged to carryaria-rowindex.fixedLayoutistable-layout: fixed. Without it the widths of the columns are computed from the rendered window and jump on every scroll.
rowCount also switches off the browser’s scroll anchoring on the scroll container: it adjusts
scrollTop when the height of the content above the visible node changes — and the window changes
it on every frame, so the list drifts the further the rougher the estimate of a row is.
Playground 15
Loading…
<GrTable />Install
npm i @feugene/granularityImport
import { GrTable } from '@feugene/granularity/components/GrTable'API
Props
| Prop | Type | default | Description |
|---|---|---|---|
size | "xs" | "sm" | "md" | "lg" | undefined | undefined | The size of the table — the base type size of the text. The paddings of the cells are left to the consumer (`GrTable` is a "thin" container). |
ariaLabel | string | undefined | undefined | A direct ARIA label for the `<table>`. It is ignored if `ariaLabelledby` is set. |
loading | boolean | undefined | false | Loading is in progress: skeletons instead of the rows, and the container is marked `aria-busy`. |
hoverable | boolean | undefined | false | A highlight of the row under the cursor. |
empty | boolean | undefined | undefined | The table is empty. By default it is determined by the content of the slot. |
emptyText | string | undefined | undefined | The text of the empty state. The `#empty` slot is stronger. |
maxHeight | string | number | undefined | undefined | The maximum height of the scroll container (it enables vertical scrolling). A number is treated as pixels. It is needed for `stickyHeader` to work. |
caption | string | undefined | undefined | The caption text for screen readers. It is rendered as `<caption class="sr-only">` if no `#caption` slot is passed. |
ariaLabelledby | string | undefined | undefined | The ID of the heading element linked to the `<table>` through `aria-labelledby`. |
regionLabel | string | undefined | undefined | The ARIA label for the scroll container. It turns on `role="region"`; the scrolling itself is always reachable from the keyboard, regardless of the label. |
stickyHeader | boolean | undefined | false | A sticky header: the `<thead>` stays visible during vertical scrolling. It makes sense together with `maxHeight` (otherwise the table does not scroll vertically). |
loadingRows | number | undefined | 3 | How many placeholder rows to show with `loading`. |
columnCount | number | undefined | 1 | How many columns a service row takes up (the empty state and the skeletons). |
striped | boolean | undefined | false | Alternation of the rows. |
rowCount | number | undefined | undefined | The full number of rows of the set, including the header rows (`aria-rowcount`). It is needed when not the whole set is in the DOM — during virtualisation, for instance: the screen reader counts the rows by the markup and would announce "5 of 20" on a table of ten thousand. The rows are then obliged to carry an `aria-rowindex`. |
fixedLayout | boolean | undefined | false | A fixed layout (`table-layout: fixed`): the widths of the columns are taken from the first row rather than from the content of all of them. It is mandatory if not the whole set is in the DOM: otherwise the widths are computed by the rendered window and jump on every scroll. The class is an arbitrary value rather than `table-fixed`: there is no such rule in `presetMini`, and the class would silently not turn into CSS. |
tableMinWidth | string | number | undefined | undefined | The minimum width of the table itself (a number means pixels). It is needed with `fixedLayout`: with a fixed layout and a width of `auto` the browser fits the table into the container and divides the space between the columns proportionally — that is, the given widths are silently squeezed, and the horizontal scrolling that the pinned columns rest on does not arise at all. |
Slots
| Slot | Type | Description |
|---|---|---|
default | any | The rows of the table, when the markup is written by hand instead of `data`. |
caption | any | The caption of the table — a `<caption>`, read by the screen reader first. |
header | any | A header instead of the one built from `columns`. |
loading | any | The content while the data is on its way — instead of the placeholder rows. |
empty | any | The empty state instead of the default text. |
footer | any | A totals row under the table. |
Methods / Expose
| Methods / Expose | Type | Description |
|---|---|---|
scrollTo | (options: ScrollToOptions) => void | Scroll the scroll container of the table — the same contract as in `GrDataTable`. |
scrollToRow | (index: number, options?: ScrollIntoViewOptions | undefined) => boolean | Scroll to a row of the content by its index in the markup. `false` — the row is not in the DOM. |
Examples 6
Basic row rendering
| Campaign | Owner | Status | Reach |
|---|---|---|---|
| Spring onboarding | Olivia | Ready | 18.2k |
| Card migration | Maksim | Review | 9.7k |
| Payout reminder | Anna | Paused | 6.3k |
<script setup lang="ts">
import type { GrBadgeTone } from '@feugene/granularity'
import { GrBadge, GrTable } from '@feugene/granularity'
interface TableRow {
campaign: string
owner: string
status: string
tag: GrBadgeTone
reach: string
}
const rows: TableRow[] = [
{ campaign: 'Spring onboarding', owner: 'Olivia', status: 'Ready', tag: 'success', reach: '18.2k' },
{ campaign: 'Card migration', owner: 'Maksim', status: 'Review', tag: 'info', reach: '9.7k' },
{ campaign: 'Payout reminder', owner: 'Anna', status: 'Paused', tag: 'warning', reach: '6.3k' },
]
</script>
<template>
<GrTable>
<template #header>
<tr>
<th class="px-4 py-3 text-left font-600">Campaign</th>
<th class="px-4 py-3 text-left font-600">Owner</th>
<th class="px-4 py-3 text-left font-600">Status</th>
<th class="px-4 py-3 text-right font-600">Reach</th>
</tr>
</template>
<tr
v-for="row in rows"
:key="row.campaign"
class="border-t border-[var(--gr-brd)]"
>
<td class="px-4 py-3">{{ row.campaign }}</td>
<td class="px-4 py-3 text-[var(--gr-muted-fg)]">{{ row.owner }}</td>
<td class="px-4 py-3">
<GrBadge size="sm" :tone="row.tag">
{{ row.status }}
</GrBadge>
</td>
<td class="px-4 py-3 text-right font-600">{{ row.reach }}</td>
</tr>
</GrTable>
</template>Loading rows with skeletons
| Task | State | Updated |
|---|---|---|
<script setup lang="ts">
import { ref } from 'vue'
import { GrButton, GrTable } from '@feugene/granularity'
const loading = ref(true)
const rows = [
{ title: 'Ledger export', state: 'Completed', updated: '2 min ago' },
{ title: 'Reconciliation', state: 'Processing', updated: '5 min ago' },
{ title: 'Fraud review', state: 'Queued', updated: '12 min ago' },
]
</script>
<template>
<div class="grid gap-3">
<div>
<GrButton size="sm" variant="outline" @click="loading = !loading">
{{ loading ? 'Show resolved rows' : 'Show loading state' }}
</GrButton>
</div>
<!-- Скелетоны рисует сама таблица, контейнер при этом помечен `aria-busy`. -->
<GrTable :loading="loading" :loading-rows="3" :column-count="3">
<template #header>
<tr>
<th class="px-4 py-3 text-left font-600">Task</th>
<th class="px-4 py-3 text-left font-600">State</th>
<th class="px-4 py-3 text-left font-600">Updated</th>
</tr>
</template>
<tr v-for="row in rows" :key="row.title" class="border-t border-[var(--gr-brd)]">
<td class="px-4 py-3">{{ row.title }}</td>
<td class="px-4 py-3">{{ row.state }}</td>
<td class="px-4 py-3 text-[var(--gr-muted-fg)]">{{ row.updated }}</td>
</tr>
</GrTable>
</div>
</template>Empty state inside tbody
| Preset | Owner | Value |
|---|---|---|
No preset rows | ||
<script setup lang="ts">
import { ref } from 'vue'
import { GrButton, GrTable } from '@feugene/granularity'
const empty = ref(true)
const rows = [
{ name: 'Risk alerts', owner: 'Ops team', value: 'Enabled' },
{ name: 'Approval SLA', owner: 'Finance', value: '24 hours' },
]
</script>
<template>
<div class="grid gap-3">
<div>
<GrButton size="sm" variant="outline" @click="empty = !empty">
{{ empty ? 'Show table rows' : 'Show empty state' }}
</GrButton>
</div>
<!-- Ни `v-if` вокруг строк, ни ручного `colspan`: пустоту таблица видит по слоту сама. -->
<GrTable :column-count="3" striped hoverable>
<template #header>
<tr>
<th class="px-4 py-3 text-left font-600">Preset</th>
<th class="px-4 py-3 text-left font-600">Owner</th>
<th class="px-4 py-3 text-left font-600">Value</th>
</tr>
</template>
<tr v-for="row in (empty ? [] : rows)" :key="row.name" class="border-t border-[var(--gr-brd)]">
<td class="px-4 py-3">{{ row.name }}</td>
<td class="px-4 py-3 text-[var(--gr-muted-fg)]">{{ row.owner }}</td>
<td class="px-4 py-3">{{ row.value }}</td>
</tr>
<template #empty>
<div class="grid justify-items-center gap-2">
<span>No preset rows</span>
<GrButton size="sm" @click="empty = false">
Load sample data
</GrButton>
</div>
</template>
</GrTable>
</div>
</template>Footer built by hand: totals and a note
| Канал | Выручка | Возвраты | К прошлому кварталу |
|---|---|---|---|
| Прямые продажи | 12 400 000 ₽ | 320 000 ₽ | +8.4% |
| Партнёры | 8 600 000 ₽ | 145 000 ₽ | +2.1% |
| Маркетплейсы | 5 100 000 ₽ | 890 000 ₽ | -6.3% |
| Итого за квартал | 26 100 000 ₽ | 1 355 000 ₽ | +3.7% |
| Возвраты за квартал учтены отдельной строкой и в выручку не входят. | |||
<script setup lang="ts">
import { GrDelta, GrTable } from '@feugene/granularity'
interface ChannelRow {
channel: string
gross: number
refunds: number
change: number
}
const rows: ChannelRow[] = [
{ channel: 'Прямые продажи', gross: 12_400_000, refunds: 320_000, change: 8.4 },
{ channel: 'Партнёры', gross: 8_600_000, refunds: 145_000, change: 2.1 },
{ channel: 'Маркетплейсы', gross: 5_100_000, refunds: 890_000, change: -6.3 },
]
const money = new Intl.NumberFormat('ru-RU', {
style: 'currency',
currency: 'RUB',
maximumFractionDigits: 0,
})
const sum = (pick: (row: ChannelRow) => number) => rows.reduce((total, row) => total + pick(row), 0)
// Колонок четыре — число нужно `colspan` примечания. `columnCount` у `GrTable`
// сюда не доезжает: он обслуживает только строки loading и empty.
const COLUMN_COUNT = 4
</script>
<template>
<GrTable>
<template #header>
<tr>
<th class="px-4 py-3 text-left font-600">
Канал
</th>
<th class="px-4 py-3 text-right font-600">
Выручка
</th>
<th class="px-4 py-3 text-right font-600">
Возвраты
</th>
<th class="px-4 py-3 text-right font-600">
К прошлому кварталу
</th>
</tr>
</template>
<tr v-for="row in rows" :key="row.channel" class="border-t border-[var(--gr-brd)]">
<td class="px-4 py-3">
{{ row.channel }}
</td>
<td class="px-4 py-3 text-right">
{{ money.format(row.gross) }}
</td>
<td class="px-4 py-3 text-right">
{{ money.format(row.refunds) }}
</td>
<td class="px-4 py-3 text-right">
<GrDelta :value="row.change" :precision="1" suffix="%" show-arrow />
</td>
</tr>
<!--
`GrTable` ячейки не оформляет принципиально, поэтому футер здесь целиком
на потребителе: отбивка, вес, паддинги, выравнивание и число колонок для
`colspan` пишутся руками и живут ровно до первой смены размера таблицы.
Нужен итог, который сам встаёт по колоночной сетке тела и едет за `size`,
шириной и закреплением, — это `summary-row` у `GrDataTable`.
-->
<template #footer>
<tr class="border-t border-[var(--gr-brd)] font-600">
<td class="px-4 py-3">
Итого за квартал
</td>
<td class="px-4 py-3 text-right">
{{ money.format(sum(row => row.gross)) }}
</td>
<td class="px-4 py-3 text-right text-[var(--gr-danger-text)]">
{{ money.format(sum(row => row.refunds)) }}
</td>
<td class="px-4 py-3 text-right">
<GrDelta :value="3.7" :precision="1" suffix="%" show-arrow />
</td>
</tr>
<tr>
<td :colspan="COLUMN_COUNT" class="px-4 py-3 text-[length:var(--gr-control-text-xs)] text-[var(--gr-muted-fg)]">
Возвраты за квартал учтены отдельной строкой и в выручку не входят.
</td>
</tr>
</template>
</GrTable>
</template>Scroll Region
| Дата | Документ | Контрагент | Счёт | Дебет | Кредит | Сальдо |
|---|---|---|---|---|---|---|
| 01.03.2026 | INV-2026-0001 | Northwind | 62.01 | 1200 ₽ | — | 300 ₽ |
| 02.03.2026 | INV-2026-0002 | Contoso | 62.02 | — | 1800 ₽ | 600 ₽ |
| 03.03.2026 | INV-2026-0003 | Fabrikam | 62.03 | 3600 ₽ | — | 900 ₽ |
| 04.03.2026 | INV-2026-0004 | Tailspin | 62.01 | — | 3600 ₽ | 1200 ₽ |
| 05.03.2026 | INV-2026-0005 | Northwind | 62.02 | 6000 ₽ | — | 1500 ₽ |
| 06.03.2026 | INV-2026-0006 | Contoso | 62.03 | — | 5400 ₽ | 1800 ₽ |
| 07.03.2026 | INV-2026-0007 | Fabrikam | 62.01 | 8400 ₽ | — | 2100 ₽ |
| 08.03.2026 | INV-2026-0008 | Tailspin | 62.02 | — | 7200 ₽ | 2400 ₽ |
| 09.03.2026 | INV-2026-0009 | Northwind | 62.03 | 10800 ₽ | — | 2700 ₽ |
| 01.03.2026 | INV-2026-0010 | Contoso | 62.01 | — | 9000 ₽ | 3000 ₽ |
| 02.03.2026 | INV-2026-0011 | Fabrikam | 62.02 | 13200 ₽ | — | 3300 ₽ |
| 03.03.2026 | INV-2026-0012 | Tailspin | 62.03 | — | 10800 ₽ | 3600 ₽ |
| 04.03.2026 | INV-2026-0013 | Northwind | 62.01 | 15600 ₽ | — | 3900 ₽ |
| 05.03.2026 | INV-2026-0014 | Contoso | 62.02 | — | 12600 ₽ | 4200 ₽ |
<script setup lang="ts">
import { GrTable } from '@feugene/granularity'
interface LedgerRow {
date: string
document: string
counterparty: string
account: string
debit: string
credit: string
balance: string
}
const rows: LedgerRow[] = Array.from({ length: 14 }, (_, index) => ({
date: `0${(index % 9) + 1}.03.2026`,
document: `INV-2026-${String(index + 1).padStart(4, '0')}`,
counterparty: ['Northwind', 'Contoso', 'Fabrikam', 'Tailspin'][index % 4],
account: `62.0${(index % 3) + 1}`,
debit: index % 2 === 0 ? `${(index + 1) * 1200} ₽` : '—',
credit: index % 2 === 0 ? '—' : `${(index + 1) * 900} ₽`,
balance: `${(index + 1) * 300} ₽`,
}))
</script>
<template>
<GrTable
region-label="Оборотная ведомость за март"
max-height="260px"
sticky-header
>
<template #header>
<tr>
<th class="px-4 py-3 text-left font-600">Дата</th>
<th class="px-4 py-3 text-left font-600">Документ</th>
<th class="px-4 py-3 text-left font-600">Контрагент</th>
<th class="px-4 py-3 text-left font-600">Счёт</th>
<th class="px-4 py-3 text-right font-600">Дебет</th>
<th class="px-4 py-3 text-right font-600">Кредит</th>
<th class="px-4 py-3 text-right font-600">Сальдо</th>
</tr>
</template>
<tr
v-for="row in rows"
:key="row.document"
class="border-t border-[var(--gr-brd)]"
>
<td class="px-4 py-3 whitespace-nowrap">{{ row.date }}</td>
<td class="px-4 py-3 whitespace-nowrap">{{ row.document }}</td>
<td class="px-4 py-3 whitespace-nowrap text-[var(--gr-muted-fg)]">{{ row.counterparty }}</td>
<td class="px-4 py-3 whitespace-nowrap">{{ row.account }}</td>
<td class="px-4 py-3 text-right whitespace-nowrap">{{ row.debit }}</td>
<td class="px-4 py-3 text-right whitespace-nowrap">{{ row.credit }}</td>
<td class="px-4 py-3 text-right font-600 whitespace-nowrap">{{ row.balance }}</td>
</tr>
</GrTable>
</template>Sizes
| Plan | Seats | Price |
|---|---|---|
| Starter | 3 | $12 |
| Team | 25 | $79 |
| Plan | Seats | Price |
|---|---|---|
| Starter | 3 | $12 |
| Team | 25 | $79 |
| Plan | Seats | Price |
|---|---|---|
| Starter | 3 | $12 |
| Team | 25 | $79 |
| Plan | Seats | Price |
|---|---|---|
| Starter | 3 | $12 |
| Team | 25 | $79 |
<script setup lang="ts">
import { GrTable } from '@feugene/granularity'
const sizes = ['xs', 'sm', 'md', 'lg'] as const
const rows = [
{ plan: 'Starter', seats: 3, price: '$12' },
{ plan: 'Team', seats: 25, price: '$79' },
]
</script>
<template>
<div class="grid gap-4">
<div v-for="size in sizes" :key="size" class="grid gap-2">
<div class="text-xs font-semibold text-[var(--gr-muted-fg)]">
size="{{ size }}"
</div>
<GrTable :size="size" aria-label="Plans">
<template #header>
<tr>
<th class="px-4 py-2 text-left">
Plan
</th>
<th class="px-4 py-2 text-right">
Seats
</th>
<th class="px-4 py-2 text-right">
Price
</th>
</tr>
</template>
<tr v-for="row in rows" :key="row.plan" class="border-t border-[var(--gr-brd)]">
<td class="px-4 py-2">
{{ row.plan }}
</td>
<td class="px-4 py-2 text-right">
{{ row.seats }}
</td>
<td class="px-4 py-2 text-right">
{{ row.price }}
</td>
</tr>
</GrTable>
</div>
</div>
</template>Accessibility
- APG pattern
—