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 = columns

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

escAccordionConnected, animated panels with shared borders and single or multiple expansion.LayoutAvatarImage, initials or icon for a person, with status dot and badge.Data displayBadgeTinted pill for status text, counts and dots, optionally pinned to a corner of another element.Data displayBreadcrumbNavigation trail of link buttons with separators, a current page and optional collapsing.NavigationButtonThemeable button with slots for text, prefix, suffix and running icons.ButtonsButton GroupAttached buttons with shared borders, optionally a segmented control with single or multiple selection.ButtonsCardGroups related content and actions on a single bordered surface.LayoutChat InputAuto-growing message box with a send button for chat interfaces.ChatChat MessageOne chat bubble with avatar, title, timestamp and message body.ChatCheckboxNative checkbox behaviour in a quiet, themeable box with an opt-in hover layer.InputsCode ViewerSyntax-highlighted code with line numbers, copy button and dark mode, powered by shiki.Data displayColor AreaTwo-dimensional area for picking saturation and value of a colour.InputsColor SelectColour swatch that opens a full picker built from area and slider.InputsColor SliderHorizontal slider for choosing a hue from 0 to 360.InputsCopy ButtonButton that copies text to the clipboard — the element it sits in, another element by id, or a literal string.ButtonsDetailsCollapsible disclosure built on native details and summary.LayoutIcon ButtonRound, hoverable button wrapping a slotted SVG icon.ButtonsKbdKeyboard key label for shortcuts and command hints, with the semantics of the native kbd element.Data displayLabelCaption that names and activates the control referenced by its for attribute, with a required marker.InputsLink ButtonText-styled control for link and navigation actions, rendered as a real anchor or a button.NavigationListVertical list container with single or multiple selection.Data displayList ItemSelectable row with icon slots, used on its own or as the option of list and select.Data displayMenuCommands, links, checkboxes and submenus in a popover anchored to a trigger.NavigationModalDialog built on the native dialog element: focus trap, backdrop, Escape, title, body and footer.FeedbackNavigation MenuSite navigation bar whose triggers open panels of links below the header.NavigationOverlayAnchored popup built on the browser Popover API, positioned with floating-ui.FeedbackPaginationPage navigation in three layouts: numbered pages, a simple page status, or a table-footer row with rows-per-page.NavigationProgressLinear progress bar, indeterminate or filling to a value, with an optional label and count.FeedbackRadioRadio options built on native inputs, grouped into one value with keyboard navigation.InputsSelectDropdown that pairs a themeable trigger with an anchored list of c2-list-item options.InputsSeperatorHorizontal or vertical rule with an optional label, for dividing content and toolbars.LayoutSheetDialog pinned to an edge of the screen, for content that complements the page rather than interrupting it.FeedbackSide NavResponsive navigation drawer beside the page: pushes the content on large screens, slides over it with a backdrop on small ones.NavigationSkeletonPlaceholder block standing in for content that has not arrived, in three shapes and three animations.FeedbackSliderRange input with a themeable track, thumb, step ticks and value bubble.InputsSpinnerCircular progress indicator, indeterminate or showing a value, with optional text.FeedbackSwitchOn/off toggle on a native switch input, with label, description and thumb icons.InputsTableVirtualized data grid with declarative columns, sorting, selection, pinning and resizing.Data displayTabsTab strip that shows one content panel at a time.NavigationText FieldSingle-line input with icon slots, clear button, helper and error text, and a character counter.InputsTextareaMultiline text input with resizing, helper and error text, and a character counter.InputsToastNotification cards and a manager for stacked, queued notifications with independent lifetimes.FeedbackTooltipContextual hint shown when its target is hovered or focused, rendered in the top layer.FeedbackFeather Icons287 open-source Feather icons, one web component each.IconsMat Icon2,234 Material Icons ligatures rendered through a single element.IconsPhosphor Icons1,512 flexible icons in six weights, one web component each.IconsThemingTheme every c2n component from a handful of design tokens with @c2n/theme, or reach for any component variable directly.GuideUsing c2n in an applicationThe workflow behind every c2n app: load the theme once, use c2-* tags directly, and turn every repeated pattern into a small variant or composed component.Guide