Record Grid

Free

A table that becomes a list of records when there is no room to be a table. Below the breakpoint the markup stops claiming to be a table and becomes a list of real <dl> label/value pairs — the structure HTML already has for this — with sorting, selection and row actions rebuilt rather than lost, and nothing dropped.

</>sv-record-grid tableresponsivecardsmobilerecords

Complexity tier: Application The interactive bar, plus a stated data-volume ceiling, a persistence contract, a bundle budget and an accessible parity surface.

import { SvRecordGridComponent } from '@styloviz/data-table';

Demo & usage

Basic usage

Usage example — open the gallery for the full interactive demo.

An interactive demo for this component lives in the gallery.

Open live demo

More examples

Why not the CSS trick

The technique everyone reaches for sets display:block on the table elements and pipes the column header into each cell through a data-label and a ::before. Two things go wrong and both are invisible. The moment those elements stop being display:table-* the browser drops their table roles, so a screen reader is handed a pile of anonymous blocks instead of rows and columns. And generated content from ::before is not text a user can select, search for or copy. The result looks like labelled cards and is less accessible than the table it replaced.

example.htmlAngular
<!-- What this renders below the breakpoint: a list of records, each a
     real description list. Not a table pretending to be blocks. -->
<ul role="list">
  <li>
    <article aria-labelledby="...">
      <h3 id="...">Ada Lovelace</h3>
      <dl>
        <div><dt>Status</dt><dd>Shipped</dd></div>
        <div><dt>Total</dt><dd>£42.00</dd></div>
      </dl>
    </article>
  </li>
</ul>

Nothing is dropped

The other common answer to a table that does not fit is to hide the columns that do not. A table that silently drops three of its eight fields on a phone is not a responsive table, it is a different table, and nobody is told which one they are reading. A column marked priority: 'secondary' moves behind a per-card disclosure that says how many are in it.

example.htmlAngular
readonly columns: TableColumn[] = [
  { key: 'customer', label: 'Customer' },
  { key: 'total', label: 'Total', sortable: true },
  { key: 'reference', label: 'Reference', priority: 'secondary' },
  { key: 'channel', label: 'Channel', priority: 'secondary' },
];

Sorting has nowhere to live in a card list

The table's sort affordance is the header cell, and a card list has no headers — so a table that becomes cards loses sorting entirely unless the control is rebuilt somewhere else. That is the half of "responsive" everyone forgets. It is a real labelled select and a real button, and it writes the choice to the two-way `sort`. The grid does not reorder `rows` itself — the host sorts the rows it binds from `sort`, exactly as for a server-sorted table — so both views show the same order.

example.htmlAngular
<sv-record-grid [columns]="columns" [rows]="sortedRows()" [(sort)]="sort" [(selected)]="picked" />

// The host owns the order — the grid reports the choice, it does not sort.
readonly sortedRows = computed(() => {
  const s = this.sort();
  if (!s?.direction) return this.rows();
  const dir = s.direction === 'asc' ? 1 : -1;
  return [...this.rows()].sort((a, b) =>
    String(a[s.key]).localeCompare(String(b[s.key]), undefined, { numeric: true }) * dir);
});

The wide view is a template, not projected content

Projected content is instantiated by the host whatever the child does with it, so a table passed in as content would sit in the accessibility tree alongside the card list — two copies of every record. An ng-template is built only when it is used.

example.htmlAngular
<sv-record-grid [columns]="columns" [rows]="rows" mode="auto" [breakpoint]="900">
  <ng-template svRecordTable>
    <sv-data-table-pro [columns]="columns" [rows]="rows" (sortChange)="onSort($event)" />
  </ng-template>
</sv-record-grid>

When to use

  • Any data table that has to work on a phone — which is most of them.
  • A table embedded in a narrow panel on a wide screen: set mode="cards" directly, because a media query cannot see the panel.
  • For a genuinely small set of fields per record, a Descriptions list on its own may be all you need.

Async states

  • loading
  • empty
  • error
  • retry

Item identity & owned state

Item key
rowKey — set this when your rows have no id, so selection survives a reorder.
Restorable state
  • expandedIds
  • selected

Bind these to put a saved view back — the open sections, the hidden columns, the selected rows — and read their Change events to store what the user does.

Inputs

NameTypeDefaultDescription
columnsreadonly TableColumn[][]The same columns the table is given.
rowsreadonly Record<string, unknown>[][]The rows currently on screen — already filtered, sorted and paged.
rowKeystring'id'The property that identifies a row. Not the index. A card list is re-rendered on every sort and every page, and tracking by position reuses the DOM of one record for another — so a checkbox stays ticked while the record under it changes.
titleKeystring''The column that titles each card. Empty uses the first column. A card needs a heading, and a `<dl>` cannot supply one: every pair in it is equally a term. Promoting one field out of the list is what makes a stack of cards scannable instead of forty identical blocks.
modeRecordGridMode'auto'Which view to render: `'auto'` switches to cards below `breakpoint`, `'table'` always shows the supplied table template, `'cards'` always shows the card list.
breakpointnumber768Width below which `'auto'` switches to cards, in px.
selectablebooleanfalseShow a checkbox on each card.
selectedSet<string> (two-way)new Set()Selected row keys. Two-way, so the host owns them across pages.
sortSortState | null (two-way)nullThe sort the user picked in the card view's sort control. Two-way. The grid does NOT reorder `rows` — they arrive already sorted, like everything else about them. The host reads this (or `(sortChange)`) and sorts `rows` itself, the same way it does for a server-sorted table; bind the table's own sort to the same state so both views agree.
rowActionsreadonly TableRowAction[][]Per-record actions, rendered in the card footer.
loadingbooleanfalseShow a skeleton list in place of either view while records load.
errorSvDataError | string | nullnullLoad failure, shown with a Retry button in place of either view; takes precedence over the empty state.
emptyMessagestring'No records to show'Text shown in place of either view when `rows` is empty.
expandedIdsReadonlySet<string> (two-way)new Set()Row keys whose secondary fields are open (two-way). A `model` so a host can restore or persist which cards are open, keyed by `rowKey` — never by position.

Outputs

EventPayload typeDescription
retryvoidEmitted when the user presses Retry in the error state; the host should re-run the failed load.
actionClick{ action: TableRowAction; row: Record<string, unknown> }Emitted with the action and its row when the user clicks one of a card's `rowActions`, after the action's `handler` has run.
selectedChangeSet<string>Emits the new `selected` whenever it changes — the output half of `[(selected)]`.
sortChangeSortState | nullEmits the new `sort` whenever it changes — the output half of `[(sort)]`.
expandedIdsChangeReadonlySet<string>Emits the new `expandedIds` whenever it changes — the output half of `[(expandedIds)]`.

<sv-record-grid-pro>

Also in this family — SvRecordGridProComponent from @styloviz-pro/data-table.

Async states

  • loading
  • empty
  • error
  • retry

Item identity & owned state

Item key
rowKey — set this when your rows have no id, so selection survives a reorder.
Restorable state
  • expandedIds
  • selected

Bind these to put a saved view back — the open sections, the hidden columns, the selected rows — and read their Change events to store what the user does.

Saved state

Stored kinds
  • sv-data-table/layout

What you will find under your own storage key. You choose the key; the blob names its own kind and schema version, so a component never reads state another one wrote.

Reading it back
Restore through the component's own restore… helper rather than casting the parsed JSON. A blob from an older release is migrated where it can be and refused with a reason where it cannot — so an upgrade never silently applies last month's shape to this month's fields.

Inputs

NameTypeDefaultDescription
columnsreadonly TableColumn[][]The same columns the table is given.
rowsreadonly Record<string, unknown>[][]The rows currently on screen — already filtered, sorted and paged.
rowKeystring'id'The property that identifies a row. Not the index. A card list is re-rendered on every sort and every page, and tracking by position reuses the DOM of one record for another — so a checkbox stays ticked while the record under it changes.
titleKeystring''The column that titles each card. Empty uses the first column. A card needs a heading, and a `<dl>` cannot supply one: every pair in it is equally a term. Promoting one field out of the list is what makes a stack of cards scannable instead of forty identical blocks.
modeRecordGridMode'auto'Which view to render: `'auto'` switches to cards below `breakpoint`, `'table'` always shows the supplied table template, `'cards'` always shows the card list.
breakpointnumber768Width below which `'auto'` switches to cards, in px.
selectablebooleanfalseShow a checkbox on each card.
selectedSet<string> (two-way)new Set()Selected row keys. Two-way, so the host owns them across pages.
sortSortState | null (two-way)nullThe sort the user picked in the card view's sort control. Two-way. The grid does NOT reorder `rows` — they arrive already sorted, like everything else about them. The host reads this (or `(sortChange)`) and sorts `rows` itself, the same way it does for a server-sorted table; bind the table's own sort to the same state so both views agree.
rowActionsreadonly TableRowAction[][]Per-record actions, rendered in the card footer.
loadingbooleanfalseShow a skeleton list in place of either view while records load.
errorSvDataError | string | nullnullLoad failure, shown with a Retry button in place of either view; takes precedence over the empty state.
emptyMessagestring'No records to show'Text shown in place of either view when `rows` is empty.
expandedIdsReadonlySet<string> (two-way)new Set()Row keys whose secondary fields are open (two-way). A `model` so a host can restore or persist which cards are open, keyed by `rowKey` — never by position.

Outputs

EventPayload typeDescription
retryvoidEmitted when the user presses Retry in the error state; the host should re-run the failed load.
actionClick{ action: TableRowAction; row: Record<string, unknown> }Emitted with the action and its row when the user clicks one of a card's `rowActions`, after the action's `handler` has run.
selectedChangeSet<string>Emits the new `selected` whenever it changes — the output half of `[(selected)]`.
sortChangeSortState | nullEmits the new `sort` whenever it changes — the output half of `[(sort)]`.
expandedIdsChangeReadonlySet<string>Emits the new `expandedIds` whenever it changes — the output half of `[(expandedIds)]`.

Wire it to your API

This component is headless about transport: it renders what you give it and reports what the user asks for. Nothing below requires @styloviz/data — drive it from whatever you already use.

Template

Bound to the names this component actually exposes.

usage.htmlAngular
<sv-record-grid
  [rows]="data.items()"
  [loading]="data.loading()"
  [error]="data.error()"
  (retry)="data.reload()"
/>

Host class

Drive the three async inputs from your own fetch.

host.component.tsTypeScript
// This component reports its state but does not emit a query, so drive
// it from your own fetch and bind the three async inputs.
readonly items = signal<Row[]>([]);
readonly loading = signal(false);
readonly error = signal<SvDataError | null>(null);

async load(): Promise<void> {
  this.loading.set(true);
  this.error.set(null);
  try {
    this.items.set(await this.api.fetchRows());
  } catch (e) {
    this.error.set(toSvDataError(e));
  } finally {
    this.loading.set(false);
  }
}
  • This component does not emit a query, so it renders what you give it. Fetch on your own trigger and bind `rows`, `loading` and `error`.
  • Normalize failures with `toSvDataError()` from `@styloviz/core`. It derives `retryable` from the status, so a 404 does not get a "Try again" button that can only fail again.

Full walkthrough: Connecting to a backend.

Customization & theming

  • `mode` (`auto` / `table` / `cards`) and `breakpoint` decide which view is on screen; `auto` measures the viewport with `matchMedia`, and re-measures when `breakpoint` changes.
  • `titleKey` promotes one field to the card heading; `priority: 'secondary'` on a column moves it behind the per-card disclosure.
  • `selectable` + two-way `selected`, two-way `sort` (the host sorts `rows` from it), and `rowActions` carry the table's contracts into the card view.
  • Two-way `expandedIds` holds which cards have their secondary fields open, keyed by `rowKey`, so a host can restore it.
  • Full async contract: `loading`, `error`, `emptyMessage` and a `retry` output, in either view.
  • Ships in both tiers: `sv-record-grid` from `@styloviz/data-table` and `sv-record-grid-pro` from `@styloviz-pro/data-table`.

Best practices

  • Do: Set `titleKey` to the field a reader would use to identify the record. A `<dl>` cannot supply a heading — every pair in it is equally a term — so one field is promoted out, and the default (the first column) is rarely the best one.
  • Do: Mark the fields that matter least as `priority: 'secondary'` rather than removing them from `columns`. They move behind a disclosure that says how many are in it.
  • Do: Bind `sort` and `selected` two-way, and sort the rows you bind from `sort` — the host sorts rows from sort; the grid only reports the choice. Keep the table's sort on the same state and switching views never loses the order or the selection.
  • Do: Set `rowKey`. Tracking by position reuses one record's DOM for another on the next sort or page, so a checkbox stays ticked while the record under it changes.
  • Don't: Don't reach for the `display: block` CSS trick instead. It drops the table roles and replaces selectable text with generated content — it looks like this and is less accessible than the table it replaced.
  • Don't: Don't pass the wide view as projected content. Use the `svRecordTable` template, or every record ends up in the accessibility tree twice.

Accessibility

  • Keyboard at scale (X-10): the grid renders every record it is given — paging is the host's. Documented ceiling: 2,000 records, past which a dev-mode warning fires; pair it with `sv-pagination` above that.
  • Below the breakpoint the markup **stops claiming to be a table**. It is a `<ul role="list">` of `<article>` cards, each labelled by its own heading and holding a real `<dl>` of `<dt>`/`<dd>` pairs — which is what a screen reader announces as "Status, definition, Shipped".
  • The labels are real text, not `::before` generated content, so they can be selected, searched and copied.
  • Every control names the record it acts on — "Select Grace Hopper", "Refund Grace Hopper", "Show 2 more fields for Ada Lovelace" — because a card list has no row context to fall back on.
  • The sort control is a labelled `<select>` with a named direction button rather than an icon, since the header cell that used to carry the affordance is gone.
  • A server render shows the table, because a prerender cannot measure and the table is the view that holds every field and every label.

Feedback on Record Grid

Spotted a gap in this page, or something that didn't work as described? Comments are GitHub Discussions in the public styloviz/community repo, so posting needs a GitHub account.

Loads giscus.app and GitHub — nothing is fetched until you click.