A Lit-based, framework-agnostic web component data grid. Ships as a single custom element <apex-grid> with a rich, opt-in feature set and full TypeScript types.
@lit-labs/virtualizer — only ~20 rows in the DOM at any time, regardless of dataset size.pageChanging / pageChanged event pair; built-in <apex-grid-paginator>.columns array is preserved.detailTemplate.getDataPath pattern over a flat array.apex-grid-enterprise.)<apex-grid-toolbar> with debounced quick filter and export menu.--ag-* CSS custom properties (no theme import or build step). Auto-matches an igniteui-webcomponents host app when one is present.role="grid" / role="treegrid", aria-rowcount, aria-colcount, focus + keyboard navigation).import { setup } from 'apex-grid';
setup();
That single call registers <apex-grid> and adopts a default host stylesheet (height: 100%; min-height: 240px). The grid is styled out-of-the-box — no theme CSS import is required.
import { html, render } from 'lit';
import 'apex-grid/define';
import type { ColumnConfiguration } from 'apex-grid';
type User = { id: number; name: string; age: number; subscribed: boolean };
const data: User[] = [
{ id: 1, name: 'Ada Lovelace', age: 36, subscribed: true },
{ id: 2, name: 'Carl Sagan', age: 62, subscribed: false },
{ id: 3, name: 'Grace Hopper', age: 85, subscribed: true },
];
const columns: ColumnConfiguration<User>[] = [
{ key: 'id', type: 'number', headerText: 'ID', width: '80px', sort: true, filter: true },
{ key: 'name', type: 'string', headerText: 'Name', width: '240px', sort: true, filter: true },
{ key: 'age', type: 'number', headerText: 'Age', width: '100px', sort: true, filter: true },
{ key: 'subscribed', type: 'boolean', headerText: 'Subscribed', width: '140px', sort: true, filter: true },
];
render(
html`<apex-grid .data=${data} .columns=${columns}></apex-grid>`,
document.getElementById('app')!,
);
<style>
apex-grid { height: 480px; }
</style>
<div id="app"></div>
If you'd rather not use setup(), this is what it does under the hood. Skipping any step produces a grid that "runs" but renders broken (no borders, no filter UI, or only a few collapsed rows).
npm install apex-grid lit
igniteui-webcomponents ships as a transitive dependency — no separate install.
import 'apex-grid/define';
Equivalent long form:
import { ApexGrid } from 'apex-grid';
ApexGrid.register();
Without this, <apex-grid> is an inert unknown element.
The grid is styled out-of-the-box — there is no theme to import. Customize it by overriding the --ag-* CSS custom properties on apex-grid (or any ancestor); a one-line brand override cascades to every tint:
apex-grid {
--ag-brand: #7c3aed; /* selection, focus, accents */
--ag-brand-strong: #6d28d9; /* hover / pressed */
--ag-radius: 12px; /* outer card radius */
--ag-row-h: 40px; /* row height */
}
See src/styles/_tokens.scss for the full token list (brand, surfaces, text, semantic state colors, typography, spacing, motion).
Grid edge / shadow. By default the grid shows a flat 1px hairline edge (no drop shadow). Control it with the --ag-grid-shadow hook — this is an opt-in override, not one of the _tokens.scss defaults:
apex-grid { --ag-grid-shadow: var(--ag-shadow-card); } /* elevated floating-card look */
apex-grid { --ag-grid-shadow: none; } /* remove the edge entirely */
If you embed the grid alongside igniteui-webcomponents, the brand tokens automatically re-tint from the igniteui palette (--ig-primary-500) — no configuration needed.
@lit-labs/virtualizer requires a bounded height. Without one, the virtualizer collapses to its natural content height (~150px) and only a few rows ever render.
apex-grid {
height: 480px; /* any explicit pixel height; % works if the parent has a height */
}
import 'apex-grid/styles.css' ships a default rule that sets height: 100% with a min-height: 240px fallback.
Do not set display on <apex-grid>. The component declares :host { display: grid } internally for its track layout (header / filter / body). Any consumer rule that sets display (including block, flex, inline-block) collapses the grid. If you accidentally do this, the grid emits a one-shot console.warn at startup pointing here.
With the element registered and the host sized, you should see:
sort: true.filter: true.<apex-grid-row> elements at any time.| What you see | Likely cause |
|---|---|
| Want a different look / brand color | Step 3 — override the --ag-* CSS variables |
| Only ~3 rows visible regardless of data size | Step 4 — no bounded height, or consumer CSS sets display on <apex-grid> (check console for the warning) |
<apex-grid> blank tag in DOM |
Step 2 — element not registered |
Columns shown as literal [object Object] |
columns= used as an attribute — must be a property (.columns=${...} in Lit, [columns]= in Angular, :columns.prop= in Vue, el.columns = ... in vanilla JS) |
Each feature below is fully opt-in — you only pay for what you turn on. Snippets assume const grid = document.querySelector('apex-grid')!.
const columns = [
{ key: 'name', sort: true }, // UI sort + tri-state
{ key: 'age', sort: { direction: 'desc' } }, // initial state
];
grid.sortConfiguration = { multiple: true, triState: true };
grid.sort({ key: 'age', direction: 'asc' });
grid.clearSort(); // or grid.clearSort('age')
When multiple is enabled, a plain header click sorts by that column alone; hold Ctrl/Cmd and click to append additional columns as lower-priority sort keys. Events: sorting (cancellable), sorted.
const columns = [
{ key: 'name', filter: true }, // UI filter chip
{ key: 'age', filter: true, type: 'number' }, // operands by type
];
import { StringOperands } from 'apex-grid';
grid.filter({ key: 'name', condition: StringOperands.contains, searchTerm: 'Ada' });
grid.clearFilter();
Operand classes: StringOperands, NumberOperands, BooleanOperands. Events: filtering (cancellable), filtered.
grid.showQuickFilter = true; // renders the toolbar input
grid.quickFilter = 'ada'; // or: await grid.setQuickFilter('ada')
Custom matcher via dataPipelineConfiguration.quickFilter. Events: quickFilterChanging (cancellable), quickFilterChanged. Attribute: show-quick-filter, quick-filter.
grid.pagination = {
enabled: true,
pageSize: 25,
pageSizeOptions: [10, 25, 50, 100],
};
await grid.gotoPage(2);
await grid.setPageSize(50);
grid.nextPage(); grid.previousPage(); grid.firstPage(); grid.lastPage();
Remote mode:
grid.pagination = { enabled: true, mode: 'remote', pageSize: 25, totalItems: 1280 };
grid.addEventListener('pageChanged', async (e) => {
grid.data = await fetchPage(e.detail.page, e.detail.pageSize);
});
Properties: page, pageSize, pageCount, totalItems, pageItems. Events: pageChanging (cancellable), pageChanged.
const columns = [
{ key: 'id', pinned: 'start' },
{ key: 'name' },
{ key: 'actions', pinned: 'end' },
];
await grid.pinColumn('name', 'start');
await grid.unpinColumn('name'); // or pinColumn('name', null)
The source columns array is not reordered — only the visual render order changes. Read grid.displayColumns for the render order. Events: columnPinning (cancellable), columnPinned.
<apex-grid column-reordering></apex-grid>
Or programmatic:
await grid.moveColumn('email', 'name', 'after');
Per-column opt-out: { key: 'id', reorderable: false }. Reordering is constrained to the column's own pinning group (start / unpinned / end). Events: columnMoving (cancellable), columnMoved. Attribute: column-reordering.
const columns = [
{ key: 'name', editable: true },
{ key: 'age', editable: true, type: 'number' },
];
grid.editing = { enabled: true, mode: 'cell', trigger: 'doubleClick' };
await grid.editCell(0, 'name');
await grid.commitEdit();
grid.cancelEdit();
mode: 'row' puts all editable cells in the row into edit together. Properties: editingCell, editingRow. Events: cellValueChanging (cancellable), cellValueChanged, plus rowEditStarted / rowEditEnded in row mode.
grid.selection = { enabled: true, mode: 'multiple', showCheckboxColumn: true };
await grid.selectRow(data[0]);
await grid.toggleRowSelection(data[1]);
await grid.selectAllRows();
await grid.clearSelection();
grid.selectedRows; // snapshot
grid.selectedRows = [data[2]]; // replace selection (goes through `rowSelecting`)
Events: rowSelecting (cancellable), rowSelected.
grid.expansion = {
enabled: true,
detailTemplate: ({ data }) => html`<order-summary .order=${data}></order-summary>`,
isExpandable: (row) => row.hasDetails,
};
await grid.expandRow(data[0]);
await grid.toggleRowExpansion(data[0]);
await grid.expandAllRows();
await grid.collapseAllRows();
grid.expandedRows; // snapshot
Events: rowExpanding (cancellable), rowExpanded.
The data array stays flat. The grid derives the hierarchy from a getDataPath(row) callback that returns the path from root to that row — AG Grid's "tree data" pattern.
type Person = { id: number; name: string; title: string; path: string[] };
const data: Person[] = [
{ id: 1, name: 'Adrian', title: 'CEO', path: ['Adrian'] },
{ id: 2, name: 'Bryan', title: 'VP Eng', path: ['Adrian', 'Bryan'] },
{ id: 3, name: 'Cara', title: 'Manager', path: ['Adrian', 'Bryan', 'Cara'] },
];
grid.tree = {
enabled: true,
getDataPath: (row) => row.path,
defaultExpanded: 1, // boolean | number — depth to expand
groupColumnKey: 'name', // which column shows the chevron + indent
childIndent: 20, // px per depth level
};
await grid.toggleTreeRow(data[0]);
await grid.expandAllTreeRows();
Methods: toggleTreeRow, expandTreeRow, collapseTreeRow, expandAllTreeRows, collapseAllTreeRows, isTreeRowExpanded. Events: treeRowExpanding (cancellable), treeRowExpanded. When tree mode is active, the host element advertises role="treegrid".
Programmatic:
grid.exportToCSV(); // downloads data.csv
grid.exportToCSV({ filename: 'users', source: 'selected' });
const text = grid.exportToCSV({ filename: '' }); // no download, returns the string
source can be 'view' (default — post-filter/post-sort), 'page', 'selected', or 'all'. Per-column opt-out: { key: 'secret', exportable: false }.
XLSX (Excel) export moved to
apex-grid-enterprisein v3.<apex-grid-enterprise>addsgrid.exportToXLSX(...)and an "Export XLSX" entry to this same toolbar menu. CSV stays free.
Toolbar dropdown:
<apex-grid show-export></apex-grid>
Renders a download icon in the toolbar's trailing actions area; the menu has an "Export CSV" entry (the enterprise grid adds "Export XLSX"). Toolbar exportFilename overrides the default data filename. Attribute: show-export.
Rendered automatically above the header row when at least one of show-quick-filter or show-export is on. CSS parts:
| Part | What |
|---|---|
toolbar |
Root container |
toolbar-search |
Quick-filter input wrapper |
search-field |
The bordered input field |
search-icon, search-input |
Leading icon, input element |
toolbar-actions |
Trailing actions area |
export-trigger |
Export menu button |
export-menu |
Dropdown panel |
export-menu-item |
Menu item |
Search input has a debounce attribute (default 200ms).
The grid styles itself through --ag-* CSS custom properties — override them on apex-grid (or any ancestor) to rebrand; see src/styles/_tokens.scss for the full list. When igniteui-webcomponents is present, the brand tokens auto-tint from its palette.
Style with CSS parts on the grid, paginator, and toolbar:
apex-grid::part(paginator) { background: var(--surface-2); }
apex-grid-toolbar::part(search-input) { font-family: var(--font-mono); }
| Property | Type | Default | Notes |
|---|---|---|---|
data |
T[] |
[] |
Source records (property only) |
columns |
ColumnConfiguration<T>[] |
[] |
Column configuration (property only) |
autoGenerate |
boolean |
false |
Infer columns from data[0] keys. Attr auto-generate |
sortConfiguration |
GridSortConfiguration |
{ multiple, triState } |
|
dataPipelineConfiguration |
DataPipelineConfiguration<T> |
— | Custom sort/filter/pagination hooks |
pagination |
PaginationConfiguration |
— | |
quickFilter |
string |
'' |
Attr quick-filter |
showQuickFilter |
boolean |
false |
Attr show-quick-filter |
showExport |
boolean |
false |
Attr show-export |
columnReordering |
boolean |
false |
Attr column-reordering |
editing |
GridEditingConfiguration |
— | |
selection |
GridSelectionConfiguration |
— | |
expansion |
GridExpansionConfiguration<T> |
— | |
tree |
GridTreeConfiguration<T> |
— | |
sortExpressions |
SortExpression<T>[] |
— | Get/set |
filterExpressions |
FilterExpression<T>[] |
— | Get/set |
selectedRows |
T[] |
— | Get/set |
expandedRows |
T[] |
— | Get/set |
page, pageSize, pageCount, totalItems |
number |
— | |
pageItems |
readonly T[] |
— | Currently rendered slice |
dataView |
readonly T[] |
— | Post-filter, post-sort |
displayColumns |
readonly ColumnConfiguration<T>[] |
— | Render order (pinned start → unpinned → pinned end) |
editingCell |
{ rowIndex, columnKey } | null |
— | |
editingRow |
number | null |
— | Row-mode only |
sort(expr): void
filter(expr): void
clearSort(key?): void
clearFilter(key?): void
setQuickFilter(value): Promise<boolean>
getColumn(keyOrIndex): ColumnConfiguration<T> | undefined
updateColumns(columns): void
pinColumn(key, 'start' | 'end' | null): Promise<boolean>
unpinColumn(key): Promise<boolean>
moveColumn(fromKey, toKey, 'before' | 'after'): Promise<boolean>
gotoPage(page): Promise<boolean>
setPageSize(size): Promise<boolean>
nextPage(); previousPage(); firstPage(); lastPage()
editCell(rowIndex, columnKey): Promise<boolean>
editRow(rowIndex): Promise<boolean>
commitEdit(): Promise<boolean>
cancelEdit(): void
selectRow(row); deselectRow(row); toggleRowSelection(row)
selectAllRows(); clearSelection(); isRowSelected(row)
expandRow(row); collapseRow(row); toggleRowExpansion(row)
expandAllRows(); collapseAllRows(); isRowExpanded(row)
toggleTreeRow(row); expandTreeRow(row); collapseTreeRow(row)
expandAllTreeRows(); collapseAllTreeRows(); isTreeRowExpanded(row)
exportToCSV(options?): string
exportAs(formatId, options?): void // toolbar dispatch; 'csv' (community), 'xlsx' (enterprise)
All events bubble and are composed across shadow boundaries. Names ending in -ing are cancellable.
| Event | Cancellable | Detail |
|---|---|---|
sorting / sorted |
yes / no | SortExpression<T>[] |
filtering / filtered |
yes / no | FilterExpression<T>[] |
quickFilterChanging / quickFilterChanged |
yes / no | { value, nextValue? } |
pageChanging / pageChanged |
yes / no | { page, pageSize, pageCount, totalItems } |
columnPinning / columnPinned |
yes / no | { key, previous, next } / { key, pinned } |
columnMoving / columnMoved |
yes / no | { key, fromIndex, toKey, position } / { key, fromIndex, toIndex } |
cellValueChanging / cellValueChanged |
yes / no | { row, column, value, newValue } |
rowEditStarted / rowEditEnded |
no / no | row context |
rowSelecting / rowSelected |
yes / no | { added, removed } |
rowExpanding / rowExpanded |
yes / no | row context |
treeRowExpanding / treeRowExpanded |
yes / no | row context |
Programmatic sort() / filter() calls are silent — only UI-initiated changes emit sorting / filtering.
auto-generate, quick-filter, show-quick-filter, show-export, column-reordering.
| Component | Parts |
|---|---|
<apex-grid-toolbar> |
toolbar, toolbar-search, search-field, search-icon, search-input, toolbar-actions, export-trigger, export-menu, export-menu-item |
<apex-grid-paginator> |
paginator, paginator-size, paginator-info, paginator-controls, paginator-page |
<apex-grid-cell> |
cell, editor |
<apex-grid> is a standard custom element. Bind properties (not attributes) for data / columns:
| Framework | Syntax |
|---|---|
| Lit | <apex-grid .data=${data} .columns=${columns}> |
| Angular | <apex-grid [data]="data" [columns]="columns"> (use CUSTOM_ELEMENTS_SCHEMA) |
| Vue | <apex-grid :data.prop="data" :columns.prop="columns"> |
| React (19+) | <apex-grid data={data} columns={columns}> |
| Vanilla | el.data = data; el.columns = columns; |
git clone https://github.com/apexcharts/apex-grid.git
cd apex-grid
npm install
npm start # demo at http://localhost:5173
npm test # web-test-runner
npm run lint
npm run build # builds dist/ + custom-elements.json + typedoc
Releases are automated by .github/workflows/publish.yml:
"version" in package.json — the single source of truth. The build injects it into dist/package.json.release: and the same version, e.g. release: 2.0.0 or release: 2.0.0-rc.1.main.The workflow then verifies the version triple-match, runs lint + tests + build, publishes dist/ to npm with --provenance (OIDC trusted publishing — no token in secrets), and creates a vX.Y.Z git tag and GitHub Release with auto-generated notes. Pre-release versions (containing -) publish under the next dist-tag; stable versions under latest.
Any push whose head commit does not start with release: is a no-op for the workflow.
See LICENSE.