Table
Virtualized data grid with declarative columns, sorting, selection, pinning and resizing.
c2-table is one CSS grid: the header row and every body row are grid items using grid-template-columns: subgrid, so columns stay aligned without any scroll syncing, pinned columns are sticky cells instead of separate containers, and the whole body scrolls in a single viewport. Columns are declared either as c2-table-column children — definitions that render nothing themselves, the same way c2-select reads its c2-list-item children — or as a columns array of the same shape.
Rows and columns are both readable from markup as JSON and from script as properties, so a table can be authored without a build step. Past virtual-threshold rows (100 by default) the body is windowed to the visible range, and an async dataSource loads blocks of rows as they scroll into view — a million rows cost the same DOM as twenty.
Give the host a height (or --c2-table--max-height); the header stays sticky while the rows scroll under it. Tab moves into the header, the arrow keys walk cells, Enter sorts the focused column, Space selects the focused row and shift-click selects a range.
Import
import '@c2n/components/table'The entry also registers c2-checkbox and c2-spinner, which it renders internally.
Usage
Put one c2-table-column per column in the default slot, and hand the table its rows. sortable on the table makes every column sortable; format runs the value through Intl; renderCell (a property) replaces a cell body with any markup.
Columns as data
columns takes the same fields as c2-table-column, as an array. Use it when the columns are computed rather than
authored — a user-configurable grid, a response that decides its own shape, or a framework template that already holds
them in state. It is a JSON attribute as well as a property, so the row above has no script behind it:
<c2-table sortable row-key="id" columns='[{"field":"name","header":"Name","width":"2fr","minWidth":140}]' rows="..."></c2-table>From script the array can carry the things an attribute cannot — renderCell, renderHeader and comparator:
import { html } from 'lit'
import type { TableColumnConfig } from '@c2n/components/table'
const columns: TableColumnConfig[] = [
{ field: 'name', header: 'Name', width: '2fr' },
{ field: 'score', header: 'Score', width: '110px', align: 'end', format: 'number' },
{
field: 'status',
header: 'Status',
width: '120px',
renderCell: ({ value }) => html`<c2-badge tone=${value === 'active' ? 'success' : 'warning'}>${value}</c2-badge>`,
},
]
table.columns = columnsThe two ways are exclusive per table, not merged: c2-table-column children win whenever there are any, so columns
is the fallback and never a way to patch a child. With neither, the table derives one column per key of the first row,
which is enough to look at a response before deciding how to present it.
Selecting rows: row keys
Selection names a row by its key, and so do value, the selection-change and row-click details and the
range-change rows. The key is the row’s row-key field as a string — with row-key="id", { "id": 7 } is the
row "7" — or whatever getRowKey(row) returns, for a key no single field holds. value stores strings, so
table.value = [7, 8] and value="7;8" select the same two rows.
table.rowKey = 'id'
table.value = [7, 8] // stored as ['7', '8']
table.getRowKey = (row) => `${row.region}:${row.sku}` // wins over row-key
table.selectRows([order]) // by row: no key to rebuild
table.selectRows(more, { add: true })
table.deselectRows([order])
table.isSelected(order) // → false
table.keyOf(order) // → 'eu:A-100'A click selects a row on its own, and clicking the row that is already the whole selection clears it again;
⌘/Ctrl-click and Space toggle one row in and out of a multiple selection, and shift-click selects a range.
Always set a key on a selectable table. Without one a row’s key is its position in the sorted rows — "0", "1",
… — so a sort, a filter or a new rows array hands the selection to whichever rows now sit there. The table warns
about it once in the console. selectRows() and deselectRows() fire selection-change, like selectAll().
Opening a row: row-activate
A table used for navigation — each row leads to a detail page — listens for row-activate. It fires for a pointer
click on a row and for Enter on a focused body cell, so one listener serves the mouse and the keyboard alike; row-click
stays pointer-only. Its detail is the row-click one (row, rowIndex, key). A click or Enter that belongs to a
control inside a cell (a button, link or input) does not activate the row, and Space never does. In a selectable table
it fires before the selection is applied, so a click or Enter both selects and activates.
row-activate does not bubble: a table nested in another table’s cell would otherwise open the outer row as well.
Put the listener on the table itself.
table.addEventListener('row-activate', (event) => {
location.href = `/services/${event.detail.key}`
})Naming the table
Name the table the way you would any grid: aria-label, or aria-labelledby pointing at a heading. Both are read
from the host and handed to the grid inside the shadow root, and follow later changes. An id cannot be resolved across
the shadow boundary, so with aria-labelledby the grid is named after the referenced elements’ text.
<h2 id="services-heading">Services</h2>
<c2-table aria-labelledby="services-heading" row-key="id"></c2-table>Totals: the summary row
A column with a summary gets a figure in a totals row below the body: sum, avg, min, max, count (the
rows whose value is not empty), or a function of the rows from script. The row belongs to the same grid as the header
and the body, so a figure stays under its own column whatever the order, width or pinning, is formatted with the
column’s format (a count is always a plain number), and is kept at the bottom of the viewport while the rows
scroll. The cell takes the column’s align; summary-align overrides it for the summary alone.
<c2-table row-key="id" rows="...">
<c2-table-column field="task" header="Task" summary-label="Total" summary-span="2"></c2-table-column>
<c2-table-column field="team" header="Team"></c2-table-column>
<c2-table-column field="hours" header="Hours" align="end" format="number" summary="sum"></c2-table-column>
<c2-table-column field="amount" header="Amount" align="end" format="currency" summary="sum"></c2-table-column>
</c2-table>Figures are keyed by the column’s id, which is its field unless you give it one — column-id on the element,
id in columns. Give one whenever two columns show the same field (an amount and its share of the total), or a
computed column has no field at all; widths, pinning and the cell-<id> parts follow the id as well. It is not the
HTML id, which would have to be unique in the whole page rather than in one table.
Aggregates cover every row; summary-scope="page" limits them to the page on show. A dataSource only holds the
blocks it has fetched, so the table does not total it (except one loaded page with summary-scope="page"). Hand it
the server’s figures instead — summaryValues sets a column’s figure outright, and wins over its aggregate:
const { rows, total, totals } = await api.orders({ start, count })
table.summaryValues = { amount: totals.amount, qty: totals.qty }renderSummary renders the cell from { column, rows, value } like renderCell does a body cell. Style the row with
--c2-table__summary--{height,background,color,font-size,font-weight,box-shadow}, and one figure with
::part(summary-content-<id>). The row is hidden while the table is empty, loading its first rows or showing an error.
Grouping rows
group-by gathers the rows into collapsible groups, outermost level first, and turns the grid into a treegrid.
With the default group-display="row" a group is a row of the same grid: its label and row count in the first
column (or the one marked group-column, which also indents the rows under it), and under every column with a
summary that aggregate over the group’s rows, formatted like the column. So one summary="sum" gives the subtotals
and the total. group-display="band" draws a full-width band per group instead, holding its label and count.
<c2-table row-key="id" group-by="region,status" selection="multiple" checkbox-selection rows="...">
<c2-table-column field="customer" header="Customer" summary-label="Total"></c2-table-column>
<c2-table-column field="region" header="Region" hidden></c2-table-column>
<c2-table-column field="amount" header="Amount" align="end" format="currency" summary="sum"></c2-table-column>
</c2-table>Groups are ordered by their value: ascending, the direction the table is sorted by that field, or a level’s own
sort. The rows inside a group keep the table’s sort. A group’s label is its value formatted by the column of that
field (hidden columns included), empty-group-label for rows without one, and renderGroup or a
slot="group:<key>" child for anything else. From script a level can compute its value:
table.groupBy = [{ field: 'month', getValue: (row) => String(row.placed).slice(0, 7), sort: 'desc' }, 'status']
table.renderGroup = ({ value, summary }) => `${value} · ${summary.amount} €`A group is named by its path, region=EMEA/status=Paid. Every group is open until expanded-groups lists the open
ones (;-separated in the attribute) or groups-collapsed starts them closed; the first click writes the whole list
into expandedGroups, so it can be stored and handed back. group-toggle reports a click, expandGroup(key),
collapseGroup(key), expandAll() and collapseAll() do it from script, and getGroups() lists every group with
its rows and aggregates. A group’s checkbox selects the rows under it (the selection still holds row keys only),
and ←/→ on a group’s first cell close, open and climb the tree.
The groups of the rows at the top of the view stay under the header while their rows scroll. Pages count display
lines, so a closed group takes one line, and a page that starts inside a group repeats that group’s headers above
its first line. Grouping is done on the client: with a dataSource the table only holds the rows it fetched, so it
ignores group-by and says so in the console. Style a group with --c2-table__group-row--*,
--c2-table__group-band--background, --c2-table__group--indent, --c2-table__group-toggle--* and
--c2-table__group-count--*, or with the group-row, group-cell, group-label and group-count parts.
Rendering a cell
renderCell is a property on the column, so it is set from script. It receives the cell’s value, the whole row and the row index, and returns anything Lit can render.
import { html } from 'lit'
import '@c2n/components/table'
import '@c2n/components/badge'
const table = document.querySelector('c2-table')!
table.rows = await fetchPeople()
const status = table.querySelector<HTMLElement & { renderCell?: unknown }>('c2-table-column[field="status"]')!
status.renderCell = ({ value }) => html`<c2-badge tone=${value === 'active' ? 'success' : 'warning'}>${value}</c2-badge>`What renderCell returns lives in the table’s shadow root, where a page stylesheet does not reach. Give it a part
and the table exposes it as is, so c2-table::part(<name>) styles it. ::part() takes no attribute or class
selector, so put the variant in the part name:
status.renderCell = ({ value }) => html`<span part="status status-${value}">${value}</span>`c2-table::part(status) {
border: 1px solid currentColor;
border-radius: 999px;
padding: 0 8px;
}
c2-table::part(status-failed) {
color: #b91c1c;
}Wrapping long text
Cells clip to one line by default, which is what a windowed grid of uniform rows needs. Add wrap and body cells
grow instead: text wraps, cells are top-aligned with --c2-table__cell__wrap--padding, and each row takes the height
of its tallest cell (at least --c2-table__row--height). Rows of different heights cannot be windowed, so wrap
turns virtualization off whatever virtual says — every row is rendered. Keep it for a few hundred rows, or page
them with page-size.
<c2-table row-key="id" wrap>
<c2-table-column field="title" header="Title" width="200px"></c2-table-column>
<c2-table-column field="notes" header="Notes" width="2fr"></c2-table-column>
</c2-table>Rendering cells with a framework, while virtualizing
A column marked cell-slot takes each cell’s body from a light-DOM child named cell:{line}:{field}, so React,
Vue, Svelte or Angular can render it with their own templates. line is the cell’s display line, counted across the
whole dataset after sort, filter and grouping with the page offset included: the first row shown is line 0 on the
first page. Only the rendered window has those slots: range-change reports one { line, key, row } entry per
rendered data row in detail.rows (and renderedRange holds the last window), so render children for those lines
only. A child whose line is outside the window is left unassigned, and a line that scrolls in before its child exists
shows the column’s formatted value until it arrives. After a sort, filter or page change the same line holds a
different row, and range-change fires again with the new rows: re-render the children from it.
The package ships that subscription as useRenderedRows, for React in @c2n/components/react and for Vue 3 in
@c2n/components/vue, the same entries that carry the JSX and template types (both frameworks are optional peer
dependencies: @c2n/components/table itself never imports them). Each one is seeded from renderedRange, follows the element when the table remounts, returns [] on the
server and before the element upgrades, and unsubscribes on unmount.
import { useRenderedRows } from '@c2n/components/react'
const tableRef = useRef<Table>(null)
const rendered = useRenderedRows(tableRef) // TableRenderedRow[]
// {rendered.map(({ line, key, row }) => <button key={key} slot={`cell:${line}:action`}>Open {row.name}</button>)}import { useTemplateRef } from 'vue'
import { useRenderedRows } from '@c2n/components/vue'
const rendered = useRenderedRows(useTemplateRef<Table>('table')) // Ref<TableRenderedRow[]>
// <button v-for="{ line, key, row } in rendered" :key="key" :slot="`cell:${line}:action`">Open {{ row.name }}</button>Any other framework wraps subscribeRenderedRows(table, (rows) => …) from @c2n/components/table, the
dependency-free core of both: it reports the rows already rendered, then every range-change, and returns the
function that unsubscribes.
Enter and Space on a control inside a cell — a slotted button, a renderCell input — belong to that control; the grid
only handles them when the cell itself has the focus.
Animating realtime row updates
Add animate-updates when rows represents successive snapshots from a realtime feed. The table matches rows using
row-key and smoothly interpolates every changed finite number, including numbers nested in arrays and objects. A
custom renderCell receives the same intermediate row, which keeps prices, derived percentages, heat colors and
embedded sparklines synchronized. With a stable row-key, additions expand into place and deletions remain briefly to
collapse out instead of making the surrounding rows jump.
<c2-table row-key="symbol" animate-updates update-duration="600" highlight-updates update-highlight-field="price">
<c2-table-column field="symbol" header="Instrument"></c2-table-column>
<c2-table-column field="price" header="Last" format="currency" currency="USD"></c2-table-column>
</c2-table>
<script type="module">
const table = document.querySelector('c2-table')
table.rows = quotes
feed.addEventListener('snapshot', (event) => {
// Rows may be added, removed or modified. Assign once; the table owns the transitions.
table.rows = event.detail.rows
})
</script>Animation remains proportional to the rendered window rather than the whole dataset, and automatically becomes an
immediate update when the user prefers reduced motion. Set animateUpdates = false or update-duration="0" when an
individual feed should jump directly to its latest state.
For a background that represents durable data state instead of a temporary update pulse, set the property-only
rowStyle callback. It receives intermediate rows during animation, so a threshold transition is smooth too:
table.rowStyle = ({ row }) => {
const position = (row.price - row.low) / (row.high - row.low)
return {
background: position <= 0.3 ? '#fef2f2' : position >= 0.7 ? '#f0fdf4' : 'transparent',
transition: 'background 240ms ease-out',
}
}Use highlight-updates for a short acknowledgement pulse; use rowStyle when color encodes a state that should
remain until the underlying data crosses another threshold.
To style rows by state from a stylesheet instead, set rowPart: the part names it returns are added to the row beside
row, since ::part(row) cannot be filtered by the row’s data.
table.rowPart = ({ row }) => (row.status === 'failed' ? 'row-failed' : undefined)c2-table::part(row-failed) {
background: #fef2f2;
}Loading rows lazily
Set dataSource instead of rows and the table asks for one block at a time as rows scroll into view, using the total to size the scrollbar. Sorting is delegated to the source: it arrives as request.sort, and the cache is dropped whenever the sort changes.
table.dataSource = {
async getRows({ start, count, sort }) {
const params = new URLSearchParams({ offset: String(start), limit: String(count) })
if (sort[0]) params.set('sort', `${sort[0].field}:${sort[0].direction}`)
const response = await fetch(`/api/people?${params}`)
const { items, total } = await response.json()
return { rows: items, total }
},
}Paging with c2-pagination
Slot a c2-pagination into the footer and the table pages its rows, driving the pager’s page, page-size and
total-items — so the pager needs no configuration of its own, and there is nothing to wire up.
<c2-table row-key="id" sortable style="height: 300px">
<c2-pagination slot="footer" variant="compact" page-size="10"></c2-pagination>
<c2-table-column field="name" header="Name" width="2fr" sortable></c2-table-column>
<c2-table-column field="score" header="Score" width="120px" align="end" format="number" sortable></c2-table-column>
</c2-table>The Paged rows card above is exactly that markup, live.
page-size is what turns paging on, and the table adopts the pager’s own when it has none — which is why the markup
above needs nothing else. With rows the page is sliced in place. With a dataSource the same markup pages
server-side, one request per page:
const table = document.querySelector('c2-table')
table.pageSize = 25
table.dataSource = {
async getRows({ start, count, sort }) {
const params = new URLSearchParams({ offset: String(start), limit: String(count) })
if (sort[0]) params.set('sort', `${sort[0].field}:${sort[0].direction}`)
const response = await fetch(`/api/people?${params}`)
const { items, total } = await response.json()
return { rows: items, total }
},
}
table.addEventListener('page-change', (event) => {
console.log(event.detail.page, event.detail.start, event.detail.count)
})One page is one request, so block-size does not apply while paging, and revisiting a page you have already seen is
served from the block cache. total in the getRows result is what sizes the pager, so a paged source should always
return it.
The table owns the numbers, so do not set page, total-items or total-pages on a pager in a footer — everything
else about it (variant, the labels, page-size-options, the styling) is still yours.
page is 1-based and reflected, so table.page = 3 moves both the table and the pager, and goToPage() does the
same. The table emits one page-change: the pager’s own event does not leave the table. Sorting starts again at page
one, since the server order has changed, and a request that fails shows the error until the next page is asked for.
@c2n/components/table deliberately does not import @c2n/components/pagination — import it yourself.