GrTable
A simple table for compact display of structured data.
Overview
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 label column has to stay visible —
stickyColumnduring horizontal scrolling; - 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.
Playground controls come from the API, not from hand
Loading…
0 changed· state is in the URL
<GrTable caption="Invoices" emptyText="No invoices yet">
<tr><td class="whitespace-nowrap px-4 py-2">Northwind</td><td class="whitespace-nowrap px-4 py-2">Oct 15</td><td class="whitespace-nowrap px-4 py-2 text-right [font-variant-numeric:tabular-nums]">€1,284.50</td></tr>
<tr><td class="whitespace-nowrap px-4 py-2">Contoso</td><td class="whitespace-nowrap px-4 py-2">Oct 12</td><td class="whitespace-nowrap px-4 py-2 text-right [font-variant-numeric:tabular-nums]">€432.00</td></tr>
<tr><td class="whitespace-nowrap px-4 py-2">Fabrikam</td><td class="whitespace-nowrap px-4 py-2">Oct 2</td><td class="whitespace-nowrap px-4 py-2 text-right [font-variant-numeric:tabular-nums]">€96.00</td></tr>
</GrTable>Examples 5 each with a live demo and its code
Your markup, the table’s frame
You write the rows, so a cell can hold anything — two lines of text, a badge, an overdue date in red. The table brings the frame, the tinted header, row hover and a
captionfor the screen reader;#footerputs the total into<tfoot>, right under its column.InvoicesOctober 2026 · 4 invoicesInvoices, October 2026 Customer Status Due Amount Northwind TradersINV-0421 · [email protected]Sent Oct 21 €1,284.50 Contoso LtdINV-0420 · [email protected]Overdue Oct 2 €432.00 Tailspin ToysINV-0419 · [email protected]Paid Sep 30 €2,150.00 Fabrikam IncINV-0418 · [email protected]Draft — €96.00 Outstanding €1,716.50 A long statement in a fixed height
max-heightscrolls the rows inside the table instead of the page,sticky-headerkeeps the column names in view, andstripedguides the eye along a row of small type.scrollToRowreaches a row by its index — here, the place where reconciliation stopped.Business account21 transactions to reconcileTransactions, business account Date Payee Category Amount Oct 6 Stripe payout Sales +€120.00 Oct 5 Lufthansa Travel −€499.00 Oct 4 Northwind Traders Sales +€878.00 Oct 3 AWS Hosting −€1,257.00 Oct 2 Contoso Ltd Sales +€1,636.00 Oct 1 WeWork Office −€2,015.00 Sep 30 Figma Software −€2,394.00 Sep 29 Stripe payout Sales +€373.00 Sep 28 Lufthansa Travel −€752.00 Sep 27 Northwind Traders Sales +€1,131.00 Sep 26 AWS Hosting −€1,510.00 Sep 25 Contoso Ltd Sales +€1,889.00 Sep 24 WeWork Office −€2,268.00 Sep 23 Figma Software −€247.00 Sep 22 Stripe payout Sales +€626.00 Sep 21 Lufthansa Travel −€1,005.00 Sep 20 Northwind Traders Sales +€1,384.00 Sep 19 AWS Hosting −€1,763.00 Sep 18 Contoso Ltd Sales +€2,142.00 Sep 17 WeWork Office −€121.00 Sep 16 Figma Software −€500.00 Sep 15 Stripe payout Last reconciled Sales +€879.00 Sep 14 Lufthansa Travel −€1,258.00 Sep 13 Northwind Traders Sales +€1,637.00 Sep 12 AWS Hosting −€2,016.00 Sep 11 Contoso Ltd Sales +€2,395.00 Sep 10 WeWork Office −€374.00 Sep 9 Figma Software −€753.00 Sep 8 Stripe payout Sales +€1,132.00 Sep 7 Lufthansa Travel −€1,511.00 Sep 6 Northwind Traders Sales +€1,890.00 Sep 5 AWS Hosting −€2,269.00 Sep 4 Contoso Ltd Sales +€248.00 Sep 3 WeWork Office −€627.00 Sep 2 Figma Software −€1,006.00 Sep 1 Stripe payout Sales +€1,385.00 Wider than the screen, the name stays
Nine months do not fit, and the table scrolls sideways — with
sticky-columnthe workspace name stays at the left edge, on an opaque background so the numbers do not show through.region-labelnames the scrolling area; the keyboard reaches it with or without one.API requests by workspaceThousands per month, 2026API requests by workspace, thousands per month Workspace Jan Feb Mar Apr May Jun Jul Aug Sep Total Acme Billing 40 65 90 115 50 75 100 125 60 720 Acme Support 77 103 129 65 91 117 143 79 105 909 Northwind 114 51 78 105 132 69 96 123 150 918 Contoso Labs 61 89 117 55 83 111 139 77 105 837 Tailspin 98 127 66 95 124 153 92 121 150 1,026 Orders that open into their lines
With
row-groupsthe body is yours: one<tbody>per order keeps an order and its lines together. The lines row is markeddata-gr-table-off-grid— it gets no stripe and no hover, and the orders below keep their parity. The button and the row it opens are linked by youraria-expandedandaria-controls.Orders this week Lines Order Customer Placed Total #10482 Northwind Traders Oct 6 €1,218.00 2 × Ergonomic chair€578.001 × Standing desk€640.00#10481 Contoso Ltd Oct 6 €948.00 #10480 Tailspin Toys Oct 5 €292.00 #10479 Fabrikam Inc Oct 5 €1,180.00 #10478 Wide World Importers Oct 4 €1,734.00 #10477 Adventure Works Oct 4 €380.00 #10476 Litware Oct 3 €1,920.00 #10475 Proseware Oct 2 €280.00
API generated from the package · cannot drift
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`. |
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. |
hoverable | boolean | undefined | false | A highlight of the row under the cursor. |
ariaLabelledby | string | undefined | undefined | The ID of the heading element linked to the `<table>` through `aria-labelledby`. |
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. |
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`. |
role | "grid" | "table" | undefined | "table" | The role of the table. `grid` — an interactive grid with the keyboard across cells (the WAI-ARIA `grid` pattern): it declares to the screen reader that the table is walked with the arrows rather than read as a static one. The role is set by whoever implements the keyboard — on its own it is a promise the table does not keep. |
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. |
rowGroups | boolean | undefined | false | The body is assembled from row groups, and they are drawn by the consumer: the default slot goes straight into the table, without wrapping in a `<tbody>`. Needed where a pair of rows is obliged to be measured and live together — the second tier of `GrDataTable` next to its row. There is nothing else in a table to wrap two `<tr>` in: a row group is the only container that exists for them. |
stickyColumn | boolean | undefined | false | A sticky first column: it stays visible during horizontal scrolling. Only the first one, and that is not a simplification: `left` from the second column on equals the sum of the widths of the previous ones, and the cells belong to the consumer — a "thin" container has nothing to measure them with. Multi-column pinning is in `GrDataTable`: it knows its columns and measures the offsets itself. |
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`): column widths are taken from the first row rather than from the content of all of them. It is mandatory when the DOM holds only part of the set: otherwise the widths are computed from the rendered window and jump on every scroll. The class is `table-fixed`. It used to be the arbitrary value `[table-layout:fixed]`, because `preset-mini` had no such rule; the `preset-wind3` vocabulary does. |
tableMinWidth | string | number | undefined | undefined | The minimum width of the table itself (a number is pixels). Needed with `fixedLayout`: with a fixed layout and an `auto` width the browser fits the table into the container and shares the room between the columns proportionally — the widths you set shrink silently, and the horizontal scroll that pinned columns rely on never appears. It is a lower bound, not a width: in a container wider than the sum of the columns the table still fills it. |
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
| Methods | Signature | 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. |
Types
Every exported type of the subpath, grouped by the file it lives in. Names, generics and bodies come from the package’s declaration files and cannot drift.
import type { GrTableProps, GrTableConfigurableProps, GrTableSize } from '@feugene/granularity/components/GrTable'Filedist/types/components/GrTable/GrTable.vue.d.ts
interface GrTableProps {
size?: GrTableSize;
caption?: string;
ariaLabel?: string;
ariaLabelledby?: string;
regionLabel?: string;
loading?: boolean;
loadingRows?: number;
columnCount?: number;
emptyText?: string;
empty?: boolean;
striped?: boolean;
hoverable?: boolean;
rowGroups?: boolean;
role?: 'table' | 'grid';
stickyHeader?: boolean;
stickyColumn?: boolean;
rowCount?: number;
fixedLayout?: boolean;
tableMinWidth?: string | number;
maxHeight?: string | number;
}Filedist/types/components/GrTable/config.d.ts
const grTableConfig: import("@feugene/granum/contract").GranumComponentDescriptor<"GrTable">;Filedist/types/components/GrTable/defaults.d.ts
interface GrTableConfigurableProps {
size: GrComponentSize;
}Filedist/types/components/GrTable/grTableStyles.d.ts
type GrTableSize = GrComponentSize;Filedist/types/components/GrTable/safelist.d.ts
const grTableSafelist: string[];Filedist/types/components/GrTable/index.d.ts
type GrTableInstance = ComponentExposed<typeof GrTableComponent>;Accessibility
APG pattern
—
Full contract of the package: docs/keyboard.md ↗
Usage from the library prose
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 sticky first column
stickyColumn keeps the first column in place during horizontal scrolling: on a wide table you
would otherwise see the numbers but not whose they are. It combines with stickyHeader — both
axes hold.
Only the first one, and that is not a simplification. The left offset from the second column
on is the sum of the widths of the previous ones, and the cells belong to you: a “thin” container
has no registry of cells and no measurements. Multi-column pinning is in
GrDataTable — it knows its columns and measures the offsets itself.
The background of a sticky cell is opaque, otherwise what is moving away shows through it.
Inheriting the background of the row will not work, although it suggests itself: the tints of
striped and hoverable are semi-transparent, and through such a cell the numbers show through —
checked by eye. So the same tints are assembled anew on top of the card: the colour is the same,
there is no hole. Striping and highlighting in the first column work on a par with the rest,
nothing needs to be written in addition.
The shadow on the right is a sign that something is moving away under the column. A border will
not do: it would shift the grid. The value is configured with the
--gr-table-sticky-column-shadow token.
min-w-full on its own does not give horizontal scrolling: tableMinWidth or
white-space: nowrap on the cells is needed.
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 table 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. Not on the
<tbody> — there may be many row groups in the body (see below), and a rule tied to one of them
would not see the alternation between them.
The sticky heading uses a local z-[1], the sticky column a z-[2], both inside their own
container —
it has nothing to do with the --gr-z-* scale, which is described in
../z-index.md.
A row outside the set: details, spacers
Striping and highlighting are written from the <tbody> and count direct children. By adding
a second row to a row — expanded details, a service spacer — you would shift the parity of every
row below and get a highlight under the cursor where there is nothing to press.
Mark such a row data-gr-table-off-grid:
<tr>
<td>Order 42</td>
</tr>
<tr data-gr-table-off-grid>
<td colspan="3">Order details</td>
</tr>The attribute removes both rules from it and excludes it from the parity count: the selector
is written as nth-child(even of :not([data-gr-table-off-grid])) rather than simply with a
filter — an ordinary :not() would remove the stripe from the row itself but would not change the
numbering of its neighbours.
Linking the pair of rows for a screen reader is also your concern: the button needs
aria-expanded and aria-controls onto the id of the details row. Ready-made expanding with its
own state, loading on demand and ARIA is in GrDataTable — here there is only
the styling.
The body in row groups
rowGroups gives the body to the consumer as a whole: the default slot goes straight into the
table, without a wrapper in <tbody>, and the groups are drawn by you.
It is needed where a pair of rows is obliged to live and be measured together. There is
nothing else in a table to wrap two <tr> in — a row group is the only container that exists for
them — and without it the virtualiser of GrDataTable would not see the
height of the second tier of an expanded row.
<GrTable row-groups>
<tbody v-for="order in orders" :key="order.id">
<tr><td>{{ order.title }}</td></tr>
<tr v-if="isOpen(order)" data-gr-table-off-grid>
<td colspan="3">Details</td>
</tr>
</tbody>
</GrTable>Striping survives that: the alternation selector is written in two forms — by rows inside the
single group and by the groups themselves — and in the foreign form each matches nothing. Mark a
group outside the set (a spacer) with the same data-gr-table-off-grid as a row.
Loading and emptiness stay with the table: it draws them itself and in a group of its own.
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.
Props in this example