GrTable

  • core
  • data
  • API · 20 props
  • ⌨ keys

A simple table for compact display of structured data.

import { GrTable } from '@feugene/granularity/components/GrTable'

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 — stickyHeader together with maxHeight;
  • the label column has to stay visible — stickyColumn during 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

NeedTake
Sorting, row selection and cell slots are neededGrDataTable
The rows are uniform and have no columnsGrList
The rows are nestedGrTree
The order of the rows is changed by the userGrSortableList
The data is shown as a chartGrChartBar

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

Stage

Loading…

0 changed· state is in the URL

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 caption for the screen reader; #footer puts the total into <tfoot>, right under its column.

    InvoicesOctober 2026 · 4 invoices
    Invoices, October 2026
    CustomerStatusDueAmount
    Northwind TradersINV-0421 · [email protected]
    SentOct 21€1,284.50
    Contoso LtdINV-0420 · [email protected]
    OverdueOct 2€432.00
    Tailspin ToysINV-0419 · [email protected]
    PaidSep 30€2,150.00
    Fabrikam IncINV-0418 · [email protected]
    Draft—€96.00
    Outstanding€1,716.50
  • A long statement in a fixed height

    max-height scrolls the rows inside the table instead of the page, sticky-header keeps the column names in view, and striped guides the eye along a row of small type. scrollToRow reaches a row by its index — here, the place where reconciliation stopped.

    Business account21 transactions to reconcile
    Transactions, business account
    DatePayeeCategoryAmount
    Oct 6Stripe payout Sales+€120.00
    Oct 5Lufthansa Travel−€499.00
    Oct 4Northwind Traders Sales+€878.00
    Oct 3AWS Hosting−€1,257.00
    Oct 2Contoso Ltd Sales+€1,636.00
    Oct 1WeWork Office−€2,015.00
    Sep 30Figma Software−€2,394.00
    Sep 29Stripe payout Sales+€373.00
    Sep 28Lufthansa Travel−€752.00
    Sep 27Northwind Traders Sales+€1,131.00
    Sep 26AWS Hosting−€1,510.00
    Sep 25Contoso Ltd Sales+€1,889.00
    Sep 24WeWork Office−€2,268.00
    Sep 23Figma Software−€247.00
    Sep 22Stripe payout Sales+€626.00
    Sep 21Lufthansa Travel−€1,005.00
    Sep 20Northwind Traders Sales+€1,384.00
    Sep 19AWS Hosting−€1,763.00
    Sep 18Contoso Ltd Sales+€2,142.00
    Sep 17WeWork Office−€121.00
    Sep 16Figma Software−€500.00
    Sep 15Stripe payout Last reconciledSales+€879.00
    Sep 14Lufthansa Travel−€1,258.00
    Sep 13Northwind Traders Sales+€1,637.00
    Sep 12AWS Hosting−€2,016.00
    Sep 11Contoso Ltd Sales+€2,395.00
    Sep 10WeWork Office−€374.00
    Sep 9Figma Software−€753.00
    Sep 8Stripe payout Sales+€1,132.00
    Sep 7Lufthansa Travel−€1,511.00
    Sep 6Northwind Traders Sales+€1,890.00
    Sep 5AWS Hosting−€2,269.00
    Sep 4Contoso Ltd Sales+€248.00
    Sep 3WeWork Office−€627.00
    Sep 2Figma Software−€1,006.00
    Sep 1Stripe payout Sales+€1,385.00
  • Wider than the screen, the name stays

    Nine months do not fit, and the table scrolls sideways — with sticky-column the workspace name stays at the left edge, on an opaque background so the numbers do not show through. region-label names the scrolling area; the keyboard reaches it with or without one.

    API requests by workspaceThousands per month, 2026
    API requests by workspace, thousands per month
    WorkspaceJanFebMarAprMayJunJulAugSepTotal
    Acme Billing406590115507510012560720
    Acme Support77103129659111714379105909
    Northwind11451781051326996123150918
    Contoso Labs6189117558311113977105837
    Tailspin981276695124153921211501,026
  • Orders that open into their lines

    With row-groups the body is yours: one <tbody> per order keeps an order and its lines together. The lines row is marked data-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 your aria-expanded and aria-controls.

    Orders this week
    LinesOrderCustomerPlacedTotal
    #10482Northwind TradersOct 6€1,218.00
    2 × Ergonomic chair€578.00
    1 × Standing desk€640.00
    #10481Contoso LtdOct 6€948.00
    #10480Tailspin ToysOct 5€292.00
    #10479Fabrikam IncOct 5€1,180.00
    #10478Wide World ImportersOct 4€1,734.00
    #10477Adventure WorksOct 4€380.00
    #10476LitwareOct 3€1,920.00
    #10475ProsewareOct 2€280.00

API generated from the package · cannot drift

Props

PropTypedefaultDescription
size"xs" | "sm" | "md" | "lg" | undefinedundefinedThe 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).
ariaLabelstring | undefinedundefinedA direct ARIA label for the `<table>`. It is ignored if `ariaLabelledby` is set.
loadingboolean | undefinedfalseLoading is in progress: skeletons instead of the rows, and the container is marked `aria-busy`.
regionLabelstring | undefinedundefinedThe ARIA label for the scroll container. It turns on `role="region"`; the scrolling itself is always reachable from the keyboard, regardless of the label.
hoverableboolean | undefinedfalseA highlight of the row under the cursor.
ariaLabelledbystring | undefinedundefinedThe ID of the heading element linked to the `<table>` through `aria-labelledby`.
emptyboolean | undefinedundefinedThe table is empty. By default it is determined by the content of the slot.
emptyTextstring | undefinedundefinedThe text of the empty state. The `#empty` slot is stronger.
maxHeightstring | number | undefinedundefinedThe maximum height of the scroll container (it enables vertical scrolling). A number is treated as pixels. It is needed for `stickyHeader` to work.
captionstring | undefinedundefinedThe caption text for screen readers. It is rendered as `<caption class="sr-only">` if no `#caption` slot is passed.
stickyHeaderboolean | undefinedfalseA sticky header: the `<thead>` stays visible during vertical scrolling. It makes sense together with `maxHeight` (otherwise the table does not scroll vertically).
loadingRowsnumber | undefined3How 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.
columnCountnumber | undefined1How many columns a service row takes up (the empty state and the skeletons).

Slots

SlotTypeDescription
defaultanyThe rows of the table, when the markup is written by hand instead of `data`.
captionanyThe caption of the table — a `<caption>`, read by the screen reader first.
headeranyA header instead of the one built from `columns`.
loadinganyThe content while the data is on its way — instead of the placeholder rows.
emptyanyThe empty state instead of the default text.
footeranyA totals row under the table.

Methods

MethodsSignatureDescription
scrollTo(options: ScrollToOptions) => voidScroll the scroll container of the table — the same contract as in `GrDataTable`.
scrollToRow(index: number, options?: ScrollIntoViewOptions | undefined) => booleanScroll 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
import type { GrTableProps, GrTableConfigurableProps, GrTableSize } from '@feugene/granularity/components/GrTable'

Filedist/types/components/GrTable/GrTable.vue.d.ts

GrTablePropsinterface
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

grTableConfigconst
const grTableConfig: import("@feugene/granum/contract").GranumComponentDescriptor<"GrTable">;

Filedist/types/components/GrTable/defaults.d.ts

GrTableConfigurablePropsinterface
interface GrTableConfigurableProps {
    size: GrComponentSize;
}

Filedist/types/components/GrTable/grTableStyles.d.ts

GrTableSizetype
type GrTableSize = GrComponentSize;

Filedist/types/components/GrTable/safelist.d.ts

grTableSafelistconst
const grTableSafelist: string[];

Filedist/types/components/GrTable/index.d.ts

GrTableInstancetype
type GrTableInstance = ComponentExposed<typeof GrTableComponent>;

Accessibility

APG pattern

—

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
vue
<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
ts
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:

vue
<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.

vue
<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:

  • rowCount is the full number of rows together with the heading ones, and it goes into aria-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 carry aria-rowindex.
  • fixedLayout is table-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.