Tables: the spec
@sportsdataverse/sdvtables is the table half of sdvplot-js: the formatting of sdvplotR's gt helpers and gtUtils
(logos in cells, colour scales, tiers, themes) as a plain-data table spec and an HTML renderer. A spec is built once,
checked against your row type and validated when built, then rendered anywhere: on a server, in a static build or in
the browser. This guide is the spec; the guides after it cover themes, cell kinds, decorations and rendering. The
sample rows are on the Sample data page.
A spec is plain data
defineTable<Row>() starts a builder; .columns, .theme, .title and the decorations each return a new builder,
and .build() validates once and returns a plain object that JSON can carry (server to browser, a file, a cache
key).
{
"columns": [
{
"kind": "logo",
"key": "team",
"height": 30,
"includeName": false,
"league": "nfl"
},
{
"kind": "int",
"key": "wins"
},
{
"kind": "int",
"key": "losses"
},
{
"kind": "colorPills",
"key": "net_epa",
"palette": [
"#C84630",
"#5DA271"
],
"fillType": "continuous",
"rankOrder": "desc",
"formatType": "number",
"scalePercent": true,
"suffix": "",
"reverse": false,
"outlineWidth": 0.25,
"pillHeight": 25,
"label": "Net EPA/play",
"digits": 3,
"domain": [
-0.2,
0.2
]
}
],
"decorations": [
{
"type": "title",
"text": "AFC, 2024"
}
],
"theme": {
"name": "midnight",
"density": "comfortable"
}
}import type { Standing } from "@sportsdataverse/examples/data";
import { defineTable } from "@sportsdataverse/sdvtables";
// defineTable<Row>() checks every key against the row type; build() validates once and returns a plain object
// that JSON can carry (server to browser, a file, a cache key). Rendering is a separate step.
defineTable<Standing>()
.columns((c) => [
c.logo("team", { league: "nfl" }),
c.int("wins"),
c.int("losses"),
c.colorPills("net_epa", { label: "Net EPA/play", digits: 3, domain: [-0.2, 0.2] }),
])
.theme("midnight")
.title("AFC, 2024")
.build();
Rendering it
renderHTML(spec, rows) returns the table as an HTML string. It is synchronous, so first await prepare(spec),
which loads the league data the spec's logo and team-colour columns need.
| Team | Wins | Losses |
|---|---|---|
| KC | 15 | 2 |
| BUF | 13 | 4 |
| LAC | 11 | 6 |
import { defineTable } from "@sportsdataverse/sdvtables";
import { renderHTML } from "@sportsdataverse/sdvtables/html";
const rows = [
{ team: "KC", wins: 15, losses: 2 },
{ team: "BUF", wins: 13, losses: 4 },
{ team: "LAC", wins: 11, losses: 6 },
];
const spec = defineTable<(typeof rows)[number]>()
.columns((c) => [c.text("team"), c.int("wins"), c.int("losses")])
.title("AFC, 2024")
.build();
renderHTML(spec, rows);
| Team | Points for | Points against |
|---|---|---|
| BUF | 525 | 368 |
| DEN | 425 | 311 |
| LAC | 402 | 301 |
import { defineTable } from "@sportsdataverse/sdvtables";
import { renderHTML } from "@sportsdataverse/sdvtables/html";
const rows = [
{ team: "BUF", pf: 525, pa: 368 },
{ team: "DEN", pf: 425, pa: 311 },
{ team: "LAC", pf: 402, pa: 301 },
];
const spec = defineTable<(typeof rows)[number]>()
.columns((c) => [c.text("team"), c.int("pf", { label: "Points for" }), c.int("pa", { label: "Points against" })])
.theme("athletic")
.build();
renderHTML(spec, rows);
| Team | Wins | Losses |
|---|---|---|
![]() | 15 | 2 |
![]() | 11 | 6 |
![]() | 10 | 7 |
![]() | 4 | 13 |
![]() | 13 | 4 |
![]() | 8 | 9 |
![]() | 5 | 12 |
![]() | 4 | 13 |
import { STANDINGS } from "@sportsdataverse/examples/data";
import { defineTable } from "@sportsdataverse/sdvtables";
import { prepare, renderHTML } from "@sportsdataverse/sdvtables/html";
export const spec = defineTable<(typeof STANDINGS)[number]>()
.columns((c) => [c.logo("team", { league: "nfl" }), c.int("wins"), c.int("losses")])
.title("AFC, 2024")
.build();
await prepare(spec); // loads the NFL shard renderHTML needs; renderHTML itself is synchronous
renderHTML(spec, STANDINGS);
The column factory and the builder
.columns((c) => [...]) hands you the column factory, the same object columnFactory<Row>() returns: each c.*
call is one column with sdvplotR's defaults, and the gtUtils names are aliases (c.fmtRank is c.rank).
RANK_PALETTE is the palette rank colours use.
{
"c.rank(\"srs_rank\")": {
"kind": "rank",
"key": "srs_rank",
"superscript": true,
"suffixSize": "0.7em"
},
"c.delta(\"pa\", \"pf\")": {
"kind": "delta",
"key": "pa",
"to": "pf",
"label": "Change",
"percent": false,
"decimals": 1,
"arrows": false,
"color": true,
"colorPositive": "#1B7837",
"colorNegative": "#B2182B",
"forceSign": true
},
"c.colorRanks(\"pf\").palette is RANK_PALETTE": true,
"RANK_PALETTE": [
"#3D8B6E",
"#9DC5A7",
"#EDE0CC",
"#DB9070",
"#BE4D3A"
],
"c.fmtRank is c.rank (the gtUtils name)": true,
"defineTable() is a TableBuilder": true,
"every method returns a new builder": true
}import type { Standing } from "@sportsdataverse/examples/data";
import { RANK_PALETTE, TableBuilder, columnFactory, defineTable } from "@sportsdataverse/sdvtables";
// .columns((c) => [...]) hands you this factory; each c.* call returns a column spec with the sdvplotR defaults.
const c = columnFactory<Standing>();
const base = defineTable<Standing>();
const titled = base.title("AFC, 2024");
{
'c.rank("srs_rank")': c.rank("srs_rank"),
'c.delta("pa", "pf")': c.delta("pa", "pf"),
'c.colorRanks("pf").palette is RANK_PALETTE': c.colorRanks("pf").palette === RANK_PALETTE,
RANK_PALETTE,
"c.fmtRank is c.rank (the gtUtils name)": c.fmtRank === c.rank,
"defineTable() is a TableBuilder": base instanceof TableBuilder,
"every method returns a new builder": titled !== base,
};
Rows picked by data
Decorations that pick rows (highlight, boldRows, spotlight, rowAccent) take a predicate such as
{ key: "wins", op: ">=", value: 11 }, or row indices, so a spec stays JSON. selectRows and matches evaluate them.
{
"wins >= 11": [
"KC",
"LAC",
"BUF"
],
"division == \"East\"": [
"BUF",
"MIA",
"NYJ",
"NE"
],
"net_epa isNull": [
"NE"
],
"team in [\"KC\", \"BUF\"]": [
"KC",
"BUF"
],
"qb matches \"^J\"": [
"LAC",
"BUF"
],
"selectRows([0, 4]) (indices pass through)": [
0,
4
],
"matches(wins > 14, KC)": true
}import { STANDINGS, type Standing } from "@sportsdataverse/examples/data";
import { type Predicate, matches, selectRows } from "@sportsdataverse/sdvtables";
// highlight, boldRows, spotlight and rowAccent take a Predicate (or row indices): plain data, so a spec stays JSON.
const teams = (p: Predicate<Standing>): string[] =>
selectRows(p, STANDINGS).map((i) => STANDINGS[i]?.team ?? "");
const [kc] = STANDINGS;
{
"wins >= 11": teams({ key: "wins", op: ">=", value: 11 }),
'division == "East"': teams({ key: "division", op: "==", value: "East" }),
"net_epa isNull": teams({ key: "net_epa", op: "isNull" }),
'team in ["KC", "BUF"]': teams({ key: "team", op: "in", value: ["KC", "BUF"] }),
'qb matches "^J"': teams({ key: "qb", op: "matches", value: "^J" }),
"selectRows([0, 4]) (indices pass through)": selectRows([0, 4], STANDINGS),
"matches(wins > 14, KC)": kc !== undefined && matches({ key: "wins", op: ">", value: 14 }, kc),
};
Errors, raised early
A spec that cannot render throws one TableSpecError (an SdvplotError) when it is built or rendered, naming the
problem: no columns, a key used twice, a column the rows do not have.
{
"build() with no columns": "TableSpecError: a table needs at least one column: call .columns(c => [...])",
"the same key twice": "TableSpecError: column key \"wins\" appears twice",
"two titles": "TableSpecError: only one .title()",
"an unknown theme": "TableSpecError: unknown theme \"neon\"; one of sdv, sdvTeam, almanac, athletic, booktabs, broadsheet, brutalist, drench, gtutils, kenpom, midnight, ncaa, pl, savant, scoreboard, sofa, swiss, terminal, tier, tufte",
"sdvTeam without a league": "TableSpecError: sdvTeam needs options.league (e.g. { league: \"nfl\", team: \"KC\" })",
"a column the rows lack": "TableSpecError: column \"net_epa\" (kind text) is not a key of the first row; keys are team, conf, division, wins, losses, ties, pf, pa, srs_rank, qb, qb_espn_id, result_last"
}import { STANDINGS, type Standing } from "@sportsdataverse/examples/data";
import { SdvplotError } from "@sportsdataverse/sdvplot";
import { TableSpecError, defineTable } from "@sportsdataverse/sdvtables";
import { renderHTML } from "@sportsdataverse/sdvtables/html";
const caught = (f: () => unknown): string => {
try {
f();
return "no error";
} catch (e) {
if (!(e instanceof TableSpecError && e instanceof SdvplotError)) throw e;
return `${e.name}: ${e.message}`;
}
};
const t = defineTable<Standing>();
const wins = t.columns((c) => [c.int("wins")]);
// Rows read from a file are untyped; here a CSV that lost its net_epa column.
const csv: Record<string, unknown>[] = STANDINGS.map(({ net_epa: _dropped, ...rest }) => rest);
const loose = defineTable<Record<string, unknown>>();
{
"build() with no columns": caught(() => t.build()),
"the same key twice": caught(() => t.columns((c) => [c.int("wins"), c.int("wins")]).build()),
"two titles": caught(() => wins.title("a").title("b").build()),
"an unknown theme": caught(() => renderHTML(wins.theme("neon").build(), STANDINGS)),
"sdvTeam without a league": caught(() => renderHTML(wins.theme("sdvTeam").build(), STANDINGS)),
"a column the rows lack": caught(() =>
renderHTML(loose.columns((c) => [c.text("team"), c.text("net_epa")]).build(), csv),
),
};
In the gallery: Table spec.







