Table themes
A theme sets everything about a table that is not its data: fonts, colours, rules, spacing. sdvtables ships the 20 themes of sdvplotR and gtUtils, each one function call on the builder, so a table can match a site, a publication or a team without any CSS of your own. The theme gallery shows all 20 on the same rows.
One theme
.theme(name) picks a theme by name. Most themes load their Google Fonts with the table.
| Team | Wins | Losses | PF | PA | Net EPA/play |
|---|---|---|---|---|---|
![]() | 15 | 2 | 385 | 326 | 0.063 |
![]() | 11 | 6 | 402 | 301 | 0.101 |
![]() | 10 | 7 | 425 | 311 | 0.108 |
![]() | 4 | 13 | 309 | 434 | -0.146 |
![]() | 13 | 4 | 525 | 368 | 0.190 |
![]() | 8 | 9 | 345 | 364 | -0.019 |
![]() | 5 | 12 | 338 | 404 | -0.045 |
![]() | 4 | 13 | 289 | 417 |
import { STANDINGS } from "@sportsdataverse/examples/data";
import { defineTable } from "@sportsdataverse/sdvtables";
import { renderHTMLAsync } from "@sportsdataverse/sdvtables/html";
const spec = defineTable<(typeof STANDINGS)[number]>()
.columns((c) => [
c.logo("team", { league: "nfl" }),
c.int("wins"),
c.int("losses"),
c.int("pf", { label: "PF" }),
c.int("pa", { label: "PA" }),
c.colorPills("net_epa", { label: "Net EPA/play", digits: 3, domain: [-0.2, 0.2] }),
])
.theme("midnight")
.title("AFC, 2024 regular season")
.build();
await renderHTMLAsync(spec, STANDINGS);
A team's own colours
sdvTeam fills the header with a team's primary colour: pass { league, team } as its options. The title takes
onColor of the fill, and the subtitle secondaryOn, a blend of the title ink toward the fill that still reads at
4.5:1.
| Team | Wins | Losses | PF | PA | Net EPA/play |
|---|---|---|---|---|---|
![]() | 15 | 2 | 385 | 326 | 0.063 |
![]() | 11 | 6 | 402 | 301 | 0.101 |
![]() | 10 | 7 | 425 | 311 | 0.108 |
![]() | 4 | 13 | 309 | 434 | -0.146 |
![]() | 13 | 4 | 525 | 368 | 0.190 |
![]() | 8 | 9 | 345 | 364 | -0.019 |
![]() | 5 | 12 | 338 | 404 | -0.045 |
![]() | 4 | 13 | 289 | 417 |
import { STANDINGS } from "@sportsdataverse/examples/data";
import { defineTable } from "@sportsdataverse/sdvtables";
import { renderHTMLAsync } from "@sportsdataverse/sdvtables/html";
const spec = defineTable<(typeof STANDINGS)[number]>()
.columns((c) => [
c.logo("team", { league: "nfl" }),
c.int("wins"),
c.int("losses"),
c.int("pf", { label: "PF" }),
c.int("pa", { label: "PA" }),
c.colorPills("net_epa", { label: "Net EPA/play", digits: 3, domain: [-0.2, 0.2] }),
])
.theme("sdvTeam", { options: { league: "nfl", team: "KC" } })
.title("AFC, 2024 regular season")
.build();
await renderHTMLAsync(spec, STANDINGS);
| Team | Primary | Title Ink | Subtitle Ink |
|---|---|---|---|
| KC | #e31837 | #ffffff | #ffffff |
| LAC | #007bc7 | #000000 | #00060a |
| DEN | #002244 | #ffffff | #8090a2 |
| LV | #000000 | #ffffff | #808080 |
| BUF | #00338d | #ffffff | #99add1 |
| MIA | #008e97 | #000000 | #001517 |
| NYJ | #003f2d | #ffffff | #8ca9a0 |
| NE | #002244 | #ffffff | #8090a2 |
import { STANDINGS } from "@sportsdataverse/examples/data";
import { loadLeague, onColor, teamColorsSync } from "@sportsdataverse/sdvplot";
import { defineTable, secondaryOn } from "@sportsdataverse/sdvtables";
import { renderHTMLAsync } from "@sportsdataverse/sdvtables/html";
// sdvTeam fills the header with the team's primary colour. The title takes onColor(primary); the subtitle takes
// secondaryOn(primary, title ink): the title ink blended toward the fill as far as it still reads at 4.5:1.
await loadLeague("nfl");
const rows = STANDINGS.map((r) => {
const primary = teamColorsSync("nfl", r.team) ?? "#0B1A33";
const title = onColor(primary);
return { team: r.team, primary, title_ink: title, subtitle_ink: secondaryOn(primary, title) };
});
const spec = defineTable<(typeof rows)[number]>()
.columns((c) => [
c.teamColorBar("team", { league: "nfl", barWidth: 8 }),
c.text("primary"),
c.text("title_ink"),
c.text("subtitle_ink"),
])
.theme("sdvTeam", { options: { league: "nfl", team: "KC" } })
.title("Kansas City, sdvTeam")
.subtitle("Each row: the header inks sdvTeam would pick for that team")
.build();
await renderHTMLAsync(spec, rows);
Density
.theme(name, { density }) scales type and padding from DENSITY: compact, comfortable or social.
| Team | Wins | Losses |
|---|---|---|
![]() | 15 | 2 |
![]() | 11 | 6 |
![]() | 10 | 7 |
| Team | Wins | Losses |
|---|---|---|
![]() | 15 | 2 |
![]() | 11 | 6 |
![]() | 10 | 7 |
| Team | Wins | Losses |
|---|---|---|
![]() | 15 | 2 |
![]() | 11 | 6 |
![]() | 10 | 7 |
import { STANDINGS, type Standing } from "@sportsdataverse/examples/data";
import { DENSITY, type Density, defineTable } from "@sportsdataverse/sdvtables";
import { renderHTMLAsync } from "@sportsdataverse/sdvtables/html";
// .theme(name, { density }) scales type and padding from DENSITY; almanac, scoreboard and terminal default to compact.
const densities: Density[] = ["compact", "comfortable", "social"];
const tables = await Promise.all(
densities.map((density) =>
renderHTMLAsync(
defineTable<Standing>()
.columns((c) => [c.logo("team", { league: "nfl" }), c.int("wins"), c.int("losses")])
.theme("sdv", { density })
.title(`density: "${density}"`)
.subtitle(`body ${DENSITY[density].body}px, cell padding ${DENSITY[density].pad}px`)
.build(),
STANDINGS.slice(0, 3),
),
),
);
tables.join("\n");
Comparing themes
themePreview(spec, rows, names) renders one table per theme (all 20 when names is left out), for choosing one.
| Team | Wins | Losses |
|---|---|---|
| KC | 15 | 2 |
| BUF | 13 | 4 |
| Team | Wins | Losses |
|---|---|---|
| KC | 15 | 2 |
| BUF | 13 | 4 |
| Team | Wins | Losses |
|---|---|---|
| KC | 15 | 2 |
| BUF | 13 | 4 |
import { defineTable } from "@sportsdataverse/sdvtables";
import { themePreview } from "@sportsdataverse/sdvtables/html";
const rows = [
{ team: "KC", wins: 15, losses: 2 },
{ team: "BUF", wins: 13, losses: 4 },
];
const spec = defineTable<(typeof rows)[number]>()
.columns((c) => [c.text("team"), c.int("wins"), c.int("losses")])
.build();
Object.values(themePreview(spec, rows, ["sdv", "athletic", "midnight"])).join("\n");
sdv
| Team | Wins | Losses |
|---|---|---|
![]() | 15 | 2 |
![]() | 11 | 6 |
![]() | 10 | 7 |
midnight
| Team | Wins | Losses |
|---|---|---|
![]() | 15 | 2 |
![]() | 11 | 6 |
![]() | 10 | 7 |
tufte
| Team | Wins | Losses |
|---|---|---|
![]() | 15 | 2 |
![]() | 11 | 6 |
![]() | 10 | 7 |
scoreboard
| Team | Wins | Losses |
|---|---|---|
![]() | 15 | 2 |
![]() | 11 | 6 |
![]() | 10 | 7 |
import { STANDINGS, type Standing } from "@sportsdataverse/examples/data";
import { defineTable } from "@sportsdataverse/sdvtables";
import { prepare, themePreview } from "@sportsdataverse/sdvtables/html";
const spec = defineTable<Standing>()
.columns((c) => [c.logo("team", { league: "nfl" }), c.int("wins"), c.int("losses")])
.title("AFC, 2024")
.build();
await prepare(spec);
// One HTML string per theme (all 20 when the list is left out), the first n rows, compact.
const previews = themePreview(spec, STANDINGS, ["sdv", "midnight", "tufte", "scoreboard"], { n: 3 });
Object.entries(previews)
.map(([name, html]) => `<h4>${name}</h4>\n${html}`)
.join("\n");
The registry
THEMES maps each name in THEME_NAMES to the function that builds it, and resolveTheme picks and calls one.
A theme's tokens (TOKEN_KEYS) become CSS custom properties on the table.
{
"THEME_NAMES": [
"sdv",
"sdvTeam",
"almanac",
"athletic",
"booktabs",
"broadsheet",
"brutalist",
"drench",
"gtutils",
"kenpom",
"midnight",
"ncaa",
"pl",
"savant",
"scoreboard",
"sofa",
"swiss",
"terminal",
"tier",
"tufte"
],
"THEMES.midnight is a function": true,
"TOKEN_KEYS.length": 53,
"midnight colour tokens": {
"bg": "#0C0D10",
"text": "#E8E9ED",
"muted": "#9498A3",
"accent": "#5b8def",
"rule": "#24272E",
"stripe": "transparent"
},
"midnight fonts": [
"Chivo"
],
"PAL_MIDNIGHT": [
"#5B8DEF",
"#3FBF87",
"#E8E9ED",
"#9498A3",
"#24272E"
]
}import { PAL_MIDNIGHT, THEMES, THEME_NAMES, TOKEN_KEYS, resolveTheme } from "@sportsdataverse/sdvtables";
// A theme is a function of { name, density, options } (THEMES[name]); resolveTheme picks and calls it.
// Its tokens (TOKEN_KEYS) become CSS custom properties on the table.
const midnight = resolveTheme({ name: "midnight", density: "comfortable" });
const { bg, text, muted, accent, rule, stripe } = midnight.tokens;
{
THEME_NAMES,
"THEMES.midnight is a function": typeof THEMES.midnight === "function",
"TOKEN_KEYS.length": TOKEN_KEYS.length,
"midnight colour tokens": { bg, text, muted, accent, rule, stripe },
"midnight fonts": midnight.fonts.map((f) => f.family),
PAL_MIDNIGHT,
};
In the gallery: Every theme and Theme API.







