AG Grid
AG Grid is a data grid for plain JavaScript, React and the other frameworks: sorting,
filtering, pinned columns, keyboard navigation and row virtualisation, so a grid of tens of thousands of rows only draws
the ones in view. @sportsdataverse/sdvtables/aggrid turns an sdvtables spec into AG Grid's grid options, so the same
spec that renders a static HTML table also drives a grid, with its number formats, logos, headshots, team colours and
theme.
Community only
AG Grid comes in two editions. AG Grid Community (ag-grid-community, and ag-grid-react for React) is MIT
licensed and free. AG Grid Enterprise (ag-grid-enterprise) is a commercial product that needs a licence key; its
features include row grouping, pivoting, aggregation, Excel export, the set and multi filters, master and detail rows,
integrated charts, cell range selection, the side bar and the status bar. sdvtables uses the Community edition only:
gridOptions sets only Community options, none of its column filters is an Enterprise one, and nothing in sdvtables or
in these examples imports ag-grid-enterprise. The package's tests check all three. If you add Enterprise yourself, its
licence terms are yours to meet.
The subpath never imports AG Grid at runtime: it uses AG Grid's types only, and the base theme comes in from your code,
so the module loads on a server. ag-grid-community is an optional peer, 36.2.0 or later; these examples run 36.2.0.
AG Grid 33 and later need their modules registered: register AllCommunityModule from ag-grid-community once, as the
examples do. Each grid below is drawn by AG Grid in your browser; without JavaScript, the page shows the same table as
sdvtables' HTML.
A spec as grid options
gridOptions(spec, rows, { theme }) returns columnDefs, rowData, getRowId, getRowStyle, rowHeight and, when
you pass a base theme, theme. Hand them to createGrid(element, options) in plain JavaScript, or spread them onto
<AgGridReact> in React, next to your own options (domLayout, initialState, pagination and the rest). It is
synchronous, so first await prepare(spec) from @sportsdataverse/sdvtables/html, which loads the leagues the logo and
team colour columns need. prepare reads only the spec, so when teamRows names a league no column uses (a plain text
team column, say), also await loadLeague(league) from @sportsdataverse/sdvplot first. The images and colours are worked out once, from the rows you pass, so build the options again
when the rows change.
Each spec column becomes one column definition, with the label and alignment the HTML table gives it. A width in px
becomes AG Grid's numeric width; a width in other units is left to AG Grid, with one warning. num, int, pct and
rank columns keep sdvtables' formats through valueFormatter (grouped thousands, fixed decimals, a forced sign,
U+2212 minus signs, ordinals with a superscript suffix) and are AG Grid number columns. A scaleNote divides its num
and int columns and suffixes their labels exactly as in the HTML table. sortable and filterable pass through, and a
column's subheader becomes its header tooltip. Filtering stays off unless you turn it on, with a column's filterable
or AG Grid's defaultColDef: { filter: true }; a number column then gets AG Grid's number filter. A blank or
unparseable value in a number column is missing: an empty cell, never "NaN" or 0, and no number filter matches it.
Sorting is sdvtables' own, through each column's comparator: a column's compare if it has one, else numbers for the
numeric kinds and text otherwise, with missing values last whether you sort ascending or descending, as in the
interactive HTML table. (AG Grid's own comparator would put them first when ascending.)
getRowId reads the spec's rowKey when it has one, and otherwise numbers the rows in the order you passed them; set a
rowKey when the rows change after the options are built. AG Grid needs a unique id per row, so gridOptions throws
when two rows share a rowKey value, when a row's rowKey value is blank, or, with no rowKey, when the same row object
appears twice.
Logos and team-coloured rows
logo and wordmark columns draw the team's image in the cell, height px tall; with includeName the name follows
the logo. The team can be an abbreviation, a name or a numeric id, as a number or as a string. A team with no image in
the archive, or one that is not found, keeps its text, and warns once, as the HTML table does. rowHeight grows so the
tallest image fits the theme's density.
The cells are AG Grid cell renderer classes that build their images with the DOM, so the same column definitions work in plain JavaScript (any page that hands AG Grid plain options) and in ag-grid-react. (ag-grid-react renders a plain function as a React component, so a renderer that returns a DOM element is a class.)
teamRows: { key, league } fills each row with its team's colour. The colours are sdvplot's team palette for the
theme's background, so a team keeps 3:1 contrast against the grid and stays distinct from the teams before it. Each
colour is laid over the background at alpha (0.4 by default, as the teamColorBg column), and the row's text turns
black or white, whichever reads better on that fill. The fill is the row's own style, so AG Grid's alternate-row colour
does not replace it, and it runs to the grid's right edge. AG Grid paints hovered and selected rows with an overlay; on a
team row that overlay is a slightly lighter or darker shade of the fill, chosen so the text reads at least as well as on
the fill itself. teamColorBg and teamColorBar columns colour a single cell the same way.
The grid below is the sample AFC standings on the sdv theme, in plain JavaScript, sorted by wins.
| Team | Division | W | L | PF | PA | Net EPA/play |
|---|---|---|---|---|---|---|
KC | West | 15 | 2 | 385 | 326 | +0.063 |
LAC | West | 11 | 6 | 402 | 301 | +0.101 |
DEN | West | 10 | 7 | 425 | 311 | +0.108 |
LV | West | 4 | 13 | 309 | 434 | −0.146 |
BUF | East | 13 | 4 | 525 | 368 | +0.190 |
MIA | East | 8 | 9 | 345 | 364 | −0.019 |
NYJ | East | 5 | 12 | 338 | 404 | −0.045 |
NE | East | 4 | 13 | 289 | 417 |
import { STANDINGS } from "@sportsdataverse/examples/data";
import { defineTable } from "@sportsdataverse/sdvtables";
import { gridOptions } from "@sportsdataverse/sdvtables/aggrid";
import { fontsLinkFor, prepare, renderHTML } from "@sportsdataverse/sdvtables/html";
// the eight AFC East and West teams, picked by id from the 2024 standings
const spec = defineTable<(typeof STANDINGS)[number]>()
.columns((c) => [
c.logo("team", { league: "nfl", includeName: true, label: "Team", height: 24, width: "110px" }),
c.text("division", { width: "90px" }),
c.int("wins", { label: "W", width: "60px" }),
c.int("losses", { label: "L", width: "60px" }),
c.int("pf", { label: "PF", width: "70px" }),
c.int("pa", { label: "PA", width: "70px" }),
c.num("net_epa", { label: "Net EPA/play", digits: 3, forceSign: true, width: "120px" }),
])
.theme("sdv")
.rowKey("team")
.build();
await prepare(spec); // the NFL logos and colours; gridOptions itself is synchronous
// In the browser this page calls mount with ag-grid-community's module and the grid's element.
export const browser = {
lib: "ag-grid",
mount: (
{ AllCommunityModule, ModuleRegistry, createGrid, themeQuartz }: typeof import("ag-grid-community"),
el: HTMLElement,
) => {
ModuleRegistry.registerModules([AllCommunityModule]); // AG Grid Community: no enterprise module
document.head.insertAdjacentHTML("beforeend", fontsLinkFor(spec)); // the sdv theme's fonts
return createGrid(el, {
...gridOptions(spec, STANDINGS, {
theme: themeQuartz,
teamRows: { key: "team", league: "nfl" },
}),
domLayout: "autoHeight",
initialState: { sort: { sortModel: [{ colId: "wins", sort: "desc" }] } },
// click a row to select it (AG Grid Community's row selection): it keeps a shade of its team's colour
rowSelection: { mode: "singleRow", checkboxes: false, enableClickSelection: true },
});
},
width: 600,
label:
"The 2024 AFC East and West standings as an AG Grid, one row per team in its team colour with its logo, sorted by wins",
} as const;
// AG Grid draws only in a browser; without JavaScript the page shows the same table as sdvtables' HTML
renderHTML(spec, STANDINGS);
On a striped theme such as savant, AG Grid paints every other row in the stripe colour; a team row's own fill covers
it, so the stripe shows only on rows whose team is not found. These grids also turn on AG Grid Community's row
selection (rowSelection: { mode: "singleRow", enableClickSelection: true }): click a row, and it keeps a shade
of its team's colour. This one is the 2025-26 NHL Atlantic Division.
| Team | W | L | OTL | Pts |
|---|---|---|---|---|
BUF | 50 | 23 | 9 | 109 |
TBL | 50 | 26 | 6 | 106 |
MTL | 48 | 24 | 10 | 106 |
BOS | 45 | 27 | 10 | 100 |
OTT | 44 | 27 | 11 | 99 |
DET | 41 | 31 | 10 | 92 |
FLA | 40 | 38 | 4 | 84 |
TOR | 32 | 36 | 14 | 78 |
import { NHL_STANDINGS } from "@sportsdataverse/examples/data";
import { defineTable } from "@sportsdataverse/sdvtables";
import { gridOptions } from "@sportsdataverse/sdvtables/aggrid";
import { fontsLinkFor, prepare, renderHTML } from "@sportsdataverse/sdvtables/html";
// the eight Atlantic rows, picked by team from the final 2025-26 standings
const spec = defineTable<(typeof NHL_STANDINGS)[number]>()
.columns((c) => [
c.logo("team", { league: "nhl", includeName: true, label: "Team", height: 24, width: "110px" }),
c.int("wins", { label: "W", width: "70px" }),
c.int("losses", { label: "L", width: "70px" }),
c.int("ot_losses", { label: "OTL", width: "70px" }),
c.int("points", { label: "Pts", width: "70px" }),
])
.theme("savant") // striped: odd rows are #F4F4F4, which a team row's own fill covers
.rowKey("team")
.build();
await prepare(spec);
// In the browser this page calls mount with ag-grid-community's module and the grid's element.
export const browser = {
lib: "ag-grid",
mount: (
{ AllCommunityModule, ModuleRegistry, createGrid, themeQuartz }: typeof import("ag-grid-community"),
el: HTMLElement,
) => {
ModuleRegistry.registerModules([AllCommunityModule]); // AG Grid Community: no enterprise module
document.head.insertAdjacentHTML("beforeend", fontsLinkFor(spec)); // the savant theme's fonts
return createGrid(el, {
...gridOptions(spec, NHL_STANDINGS, {
theme: themeQuartz,
teamRows: { key: "team", league: "nhl" },
}),
domLayout: "autoHeight",
// click a row to select it (AG Grid Community's row selection): it keeps a shade of its team's colour
rowSelection: { mode: "singleRow", checkboxes: false, enableClickSelection: true },
});
},
width: 480,
label:
"The 2025-26 NHL Atlantic Division standings as an AG Grid on the striped savant theme, one row per team in its team colour with its logo",
} as const;
// AG Grid draws only in a browser; without JavaScript the page shows the same table as sdvtables' HTML
renderHTML(spec, NHL_STANDINGS);
Headshots
headshot columns draw ESPN player headshots from the cell's ESPN athlete id, or from an nflverse gsis id with
idSystem: "gsis" (prepare loads the gsis map). An id with no headshot keeps the id as text and warns once. A
headshot has an empty alt: it is decorative, as the player's name belongs in a column of its own beside it.
Thousands of rows
AG Grid draws only the rows in view, plus a few either side, and draws the others as you scroll. This grid holds all 11,778 rush and pass plays of the first six weeks of the 2024 NFL season, from nflverse's play-by-play release. It is sorted by EPA, the offence's logo is pinned to the left while the other columns scroll, and every column has a filter. Only the logos of the rows in view are loaded.
With JavaScript on, AG Grid draws this figure here.
{
"plays": 11778,
"games": 92,
"columns": [
"posteam",
"defteam",
"week",
"qtr",
"clock",
"down",
"ydstogo",
"yardline_100",
"play_type",
"yards_gained",
"epa",
"wp"
]
}import { nflPlays2024 } from "@sportsdataverse/examples/data";
import { defineTable } from "@sportsdataverse/sdvtables";
import { gridOptions } from "@sportsdataverse/sdvtables/aggrid";
import { prepare } from "@sportsdataverse/sdvtables/html";
// nflverse's 2024 play-by-play, weeks 1 to 6: 11,778 rows, each keyed by its play id
const plays = await nflPlays2024();
const spec = defineTable<(typeof plays)[number]>()
.columns((c) => [
c.logo("posteam", { league: "nfl", includeName: true, label: "Offence", height: 20, width: "110px" }),
c.logo("defteam", { league: "nfl", label: "Defence", height: 20, width: "80px" }),
c.int("week", { label: "Wk", width: "64px" }),
c.int("qtr", { label: "Q", width: "56px" }),
c.text("clock", { label: "Clock", width: "76px" }),
c.int("down", { label: "Down", width: "72px" }),
c.int("ydstogo", { label: "To go", width: "76px" }),
c.int("yardline_100", { label: "Yds to EZ", width: "96px" }),
c.text("play_type", { label: "Play", width: "80px" }),
c.int("yards_gained", { label: "Yards", width: "76px" }),
c.num("epa", { label: "EPA", digits: 2, forceSign: true, width: "80px" }),
c.pct("wp", { label: "Win prob.", digits: 0, scale: true, width: "96px" }),
])
.theme("sdv", { density: "compact" })
.rowKey("play")
.build();
await prepare(spec);
// In the browser this page calls mount with ag-grid-community's module and the grid's element.
export const browser = {
lib: "ag-grid",
mount: (
{ AllCommunityModule, ModuleRegistry, createGrid, themeQuartz }: typeof import("ag-grid-community"),
el: HTMLElement,
) => {
ModuleRegistry.registerModules([AllCommunityModule]); // AG Grid Community: no enterprise module
const options = gridOptions(spec, plays, { theme: themeQuartz });
return createGrid(el, {
...options,
// the offence stays in view while the other columns scroll; sorted by EPA, high to low
columnDefs: options.columnDefs.map((d) =>
d.colId === "posteam"
? { ...d, pinned: "left" as const }
: d.colId === "epa"
? { ...d, sort: "desc" as const }
: d,
),
// every column sorts and filters; only the rows in view are in the page, the others drawn as they scroll in
defaultColDef: { filter: true },
});
},
width: 720,
height: 480,
label:
"The 11,778 rush and pass plays of weeks 1 to 6 of the 2024 NFL regular season in a scrolling AG Grid, the offence's logo pinned left, sorted by EPA",
} as const;
// AG Grid draws only in a browser; without JavaScript the page shows what the grid holds
{
plays: plays.length,
games: new Set(plays.map((p) => p.game_id)).size,
columns: spec.columns.map((c) => c.key),
};
Themes, dark ones included
Pass AG Grid's base theme as theme (themeQuartz, themeAlpine or themeBalham from ag-grid-community), and
gridOptions returns it with the sdvtables theme's colours and fonts applied through AG Grid's Theming API: background,
text, muted text, rules, accent, the header's background, text colour, font, weight and size, the body font and size,
and the stripe as AG Grid's odd-row colour. The header's case, tracking and italics, which AG Grid has no theme setting
for, are set on each header. A dark background also sets AG Grid's dark colour scheme for its scrollbars and inputs.
Density sets the row height: 32 px for compact, AG Grid's own 42 px for comfortable and 52 px for social.
A dark theme such as midnight needs nothing more: the grid takes its dark background and light text, and team rows
take the palette for that dark background, so a navy team lightens until it reads. This grid is React, through
ag-grid-react, and keys its rows by stats.nba.com's numeric team id, which resolves to each team's logo and colour.
| Rank | Team | W | L | Win % | |
|---|---|---|---|---|---|
| 1st | ![]() | LA Clippers | 51 | 31 | 62.2% |
| 2nd | ![]() | Phoenix Suns | 49 | 33 | 59.8% |
| 3rd | ![]() | Los Angeles Lakers | 47 | 35 | 57.3% |
| 4th | ![]() | Sacramento Kings | 46 | 36 | 56.1% |
| 5th | ![]() | Golden State Warriors | 46 | 36 | 56.1% |
import { NBA_STANDINGS } from "@sportsdataverse/examples/data";
import { defineTable } from "@sportsdataverse/sdvtables";
import { gridOptions } from "@sportsdataverse/sdvtables/aggrid";
import { fontsLinkFor, prepare, renderHTML } from "@sportsdataverse/sdvtables/html";
import { AllCommunityModule, ModuleRegistry, themeQuartz } from "ag-grid-community";
import { AgGridReact, type AgGridReactProps } from "ag-grid-react";
import { useEffect } from "react";
ModuleRegistry.registerModules([AllCommunityModule]); // AG Grid Community: no enterprise module
// the five Pacific rows, keyed by stats.nba.com's numeric TeamID: the logos and row colours resolve from the number
const rows = NBA_STANDINGS.map((s) => ({
team_id: s.team_id,
name: `${s.city} ${s.team}`,
wins: s.wins,
losses: s.losses,
win_pct: s.wins / (s.wins + s.losses),
division_rank: s.division_rank,
}));
type Row = (typeof rows)[number];
const spec = defineTable<Row>()
.columns((c) => [
c.rank("division_rank", { label: "Rank", width: "80px" }),
c.logo("team_id", { league: "nba", label: "", height: 32, sortable: false, width: "64px" }),
c.text("name", { label: "Team", width: "190px" }),
c.int("wins", { label: "W", width: "70px" }),
c.int("losses", { label: "L", width: "70px" }),
c.pct("win_pct", { label: "Win %", digits: 1, scale: true, width: "90px" }),
])
.theme("midnight")
.rowKey("team_id")
.build();
await prepare(spec);
// the midnight theme as AG Grid theme params over Quartz; every row in its team's dark-mode colour at 30%
const options = gridOptions(spec, rows, {
theme: themeQuartz,
teamRows: { key: "team_id", league: "nba", alpha: 0.3 },
});
// the drawer passes onGridReady, to hand the grid to the render test
function Pacific(props: Pick<AgGridReactProps<Row>, "onGridReady">) {
useEffect(() => document.head.insertAdjacentHTML("beforeend", fontsLinkFor(spec)), []);
// click a row to select it (AG Grid Community's row selection): it keeps a shade of its team's colour
return (
<AgGridReact<Row>
{...options}
{...props}
domLayout="autoHeight"
rowSelection={{ mode: "singleRow", checkboxes: false, enableClickSelection: true }}
/>
);
}
export const browser = {
lib: "ag-grid-react",
grid: () => <Pacific />,
width: 600,
label:
"The 2023-24 Pacific Division as an AG Grid on a dark background, each row in its team's colour with its logo, ranked by division",
} as const;
// AG Grid draws only in a browser; without JavaScript the page shows the same table as sdvtables' HTML
renderHTML(spec, rows);
The themes name their fonts but do not load them: put the <link> that fontsLinkFor(spec) returns in the page's
<head>, as these examples do.
What is not mapped
The cell kinds that draw in HTML or SVG (pills, bars, sparklines, gauges, icons and the rest) show their plain value in
the grid, with one warning per kind; render those tables with renderHTML or <SdvTable> instead. Decorations
(titles, source notes, spotlights, legends) are the page's to add around the grid; a scaleNote scales its columns and
labels, but its note is yours to place. A centred column's header stays left-aligned, as AG Grid has no centred header.
Logos are plain <img> elements, so the grid's CSV export keeps the text, not the images.
Testing and server rendering
Each image carries data-sdv-id (the team or player id) and data-sdv-kind (logo, wordmark or headshot), so a
test can read back which images a grid drew and in which rows. The options are plain values, functions and classes, and
the cell renderers touch the DOM only when AG Grid draws a cell, so building them on a server is safe; AG Grid itself
draws in the browser.
In the gallery: AG Grid.
KC
LAC
DEN
LV
BUF
MIA
NYJ
NE
BUF
TBL
MTL
BOS
OTT
DET
FLA
TOR



