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.
Installation
npm install @c2n/table@c2n/checkbox and @c2n/spinner are dependencies of the package and are registered for you.
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"}]' 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/table/table-types.js'
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.
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/table'
import '@c2n/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>`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/pagination is deliberately not a dependency of @c2n/table — install and import it yourself.