HTML, SSR and the host page
@sportsdataverse/sdvtables/html turns a spec and rows into HTML. The output is a string, so the same call serves a
server response, a static build or a browser page, and it carries its own CSS and font links so it renders the same
wherever it lands. This guide covers the choices that matter once tables sit in a real page: when to await, sharing
one stylesheet across many tables, and who loads the fonts.
Prepare, then render
renderHTML is synchronous and needs the league data loaded; prepare(spec) loads it. renderHTMLAsync(spec, rows)
is the two in one call, for code that can await.
| Team | QB | Wins | Losses | |
|---|---|---|---|---|
KC | ![]() | Patrick Mahomes | 15 | 2 |
LAC | ![]() | Justin Herbert | 11 | 6 |
DEN | ![]() | Bo Nix | 10 | 7 |
LV | ![]() | Gardner Minshew | 4 | 13 |
BUF | ![]() | Josh Allen | 13 | 4 |
MIA | ![]() | Tua Tagovailoa | 8 | 9 |
NYJ | ![]() | Aaron Rodgers | 5 | 12 |
NE | ![]() | Drake Maye | 4 | 13 |
import { STANDINGS, type Standing } from "@sportsdataverse/examples/data";
import { defineTable } from "@sportsdataverse/sdvtables";
import { renderHTMLAsync } from "@sportsdataverse/sdvtables/html";
// renderHTMLAsync(spec, rows) is await prepare(spec) then renderHTML(spec, rows): use it where you can await.
const spec = defineTable<Standing>()
.columns((c) => [
c.logo("team", { league: "nfl", includeName: true }),
c.headshot("qb_espn_id", { league: "nfl", label: "QB" }),
c.text("qb", { label: "" }),
c.int("wins"),
c.int("losses"),
])
.title("AFC, 2024")
.build();
await renderHTMLAsync(spec, STANDINGS);
What a spec needs and shows
leaguesOf(spec) lists the leagues prepare will load (team-aware columns and the sdvTeam theme), and
columnLabel(column) gives the header text a column will show (its label, else the key, title-cased).
{
"leaguesOf(teams)": [
"nfl"
],
"leaguesOf(players)": [],
"teams.columns.map(columnLabel)": [
"Team",
"Wins",
"Net EPA/play"
],
"players.columns.map(columnLabel)": [
"Qb Espn Id",
"Qb"
]
}import type { Standing } from "@sportsdataverse/examples/data";
import { defineTable } from "@sportsdataverse/sdvtables";
import { columnLabel, leaguesOf } from "@sportsdataverse/sdvtables/html";
// leaguesOf lists the league shards prepare() will load: team-aware columns and the sdvTeam theme.
// ESPN headshot URLs are built from the id alone, so a headshot column needs none.
const teams = defineTable<Standing>()
.columns((c) => [
c.logo("team", { league: "nfl" }),
c.int("wins"),
c.num("net_epa", { label: "Net EPA/play" }),
])
.build();
const players = defineTable<Standing>()
.columns((c) => [c.headshot("qb_espn_id", { league: "nfl" }), c.text("qb")])
.build();
{
"leaguesOf(teams)": leaguesOf(teams),
"leaguesOf(players)": leaguesOf(players),
// a label falls back to the key, title-cased
"teams.columns.map(columnLabel)": teams.columns.map(columnLabel),
"players.columns.map(columnLabel)": players.columns.map(columnLabel),
};
One stylesheet for many tables
Each table inlines its theme's CSS by default. A page with many tables in one theme writes styleSheet(spec) once per
themeKey(spec.theme) (the class every table of that theme carries) and renders each table with { css: "none" }.
Both tables carry the class sdvt-t-445aaff9 (east: sdvt-t-445aaff9).
| Team | Wins | Losses |
|---|---|---|
KC | 15 | 2 |
LAC | 11 | 6 |
DEN | 10 | 7 |
LV | 4 | 13 |
| Team | Wins | Losses |
|---|---|---|
BUF | 13 | 4 |
MIA | 8 | 9 |
NYJ | 5 | 12 |
NE | 4 | 13 |
import { STANDINGS, type Standing } from "@sportsdataverse/examples/data";
import { defineTable } from "@sportsdataverse/sdvtables";
import { prepare, renderHTML, styleSheet, themeKey } from "@sportsdataverse/sdvtables/html";
const division = (name: string) =>
defineTable<Standing>()
.columns((c) => [c.logo("team", { league: "nfl", includeName: true }), c.int("wins"), c.int("losses")])
.theme("athletic")
.title(`AFC ${name}, 2024`)
.build();
const west = division("West");
const east = division("East");
await prepare(west);
// Same theme, density and options, so the same themeKey class: the host page writes styleSheet() once per key
// and renders each table with css: "none".
const key = themeKey(west.theme);
[
`<style>${styleSheet(west)}</style>`,
`<p>Both tables carry the class <code>${key}</code> (east: <code>${themeKey(east.theme)}</code>).</p>`,
renderHTML(
west,
STANDINGS.filter((r) => r.division === "West"),
{ css: "none" },
),
renderHTML(
east,
STANDINGS.filter((r) => r.division === "East"),
{ css: "none" },
),
].join("\n");
Fonts the page already loads
Every table starts with a Google Fonts <link> for its theme; { fonts: false } leaves it out, so the page can load
the fonts once in its <head>.
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Spline+Sans+Mono:wght@500&family=Work+Sans:wght@500;650&display=swap">
| Team | Wins | Losses |
|---|---|---|
KC | 15 | 2 |
LAC | 11 | 6 |
DEN | 10 | 7 |
LV | 4 | 13 |
BUF | 13 | 4 |
MIA | 8 | 9 |
NYJ | 5 | 12 |
NE | 4 | 13 |
import { STANDINGS, type Standing } from "@sportsdataverse/examples/data";
import { defineTable, resolveTheme } from "@sportsdataverse/sdvtables";
import { fontsLink, prepare, renderHTML } from "@sportsdataverse/sdvtables/html";
const spec = defineTable<Standing>()
.columns((c) => [c.logo("team", { league: "nfl", includeName: true }), c.int("wins"), c.int("losses")])
.theme("athletic")
.title("AFC, 2024")
.build();
await prepare(spec);
// Every table starts with a Google Fonts <link> for its theme. fonts: false leaves it out; the page loads them
// once instead, e.g. with this <link> in its <head>:
const head = fontsLink(resolveTheme(spec.theme).fonts);
const escaped = head.replace(/&/g, "&").replace(/</g, "<");
`<p><code>${escaped}</code></p>\n${renderHTML(spec, STANDINGS, { fonts: false })}`;
A DOM element
toElement(spec, rows) returns the table as an element instead of a string, and moves the theme's font link into
<head> once. It needs a DOM (a browser, jsdom or happy-dom).
| Team | Qb | Wins | Losses |
|---|---|---|---|
![]() | Patrick Mahomes | 15 | 2 |
![]() | Justin Herbert | 11 | 6 |
![]() | Bo Nix | 10 | 7 |
![]() | Gardner Minshew | 4 | 13 |
![]() | Josh Allen | 13 | 4 |
![]() | Tua Tagovailoa | 8 | 9 |
![]() | Aaron Rodgers | 5 | 12 |
![]() | Drake Maye | 4 | 13 |
import { STANDINGS, type Standing } from "@sportsdataverse/examples/data";
import { defineTable } from "@sportsdataverse/sdvtables";
import { prepare, toElement } from "@sportsdataverse/sdvtables/html";
// toElement needs a DOM (a browser, jsdom or happy-dom); it moves the theme's font <link> into <head> once.
const spec = defineTable<Standing>()
.columns((c) => [c.logo("team", { league: "nfl" }), c.text("qb"), c.int("wins"), c.int("losses")])
.theme("pl")
.title("AFC, 2024")
.build();
await prepare(spec);
toElement(spec, STANDINGS);
Sort, filter, page
createTable(spec, rows, options) is sdvtables' own headless engine: sort, a search box over every shown column, a
filter box per filterable column, paging, column visibility and row selection, with immutable snapshots and
subscribe. renderHTML(table) draws it with sort buttons (aria-sort on the sorted header), the boxes and a pager,
and hydrate(el, table) makes that markup live: each change re-renders only the table block and the pager. Missing
values sort last in both directions. Click a header, type in a box or page through the eight teams below.
KC | West | 15 | 2 | 385 | 326 |
BUF | East | 13 | 4 | 525 | 368 |
LAC | West | 11 | 6 | 402 | 301 |
DEN | West | 10 | 7 | 425 | 311 |
import { STANDINGS, type Standing } from "@sportsdataverse/examples/data";
import { createTable, defineTable } from "@sportsdataverse/sdvtables";
import { hydrate, prepare, renderHTML } from "@sportsdataverse/sdvtables/html";
const spec = defineTable<Standing>()
.columns((c) => [
c.logo("team", { league: "nfl", includeName: true }),
c.text("division", { filterable: true }),
c.int("wins"),
c.int("losses"),
c.int("pf", { label: "PF" }),
c.int("pa", { label: "PA" }),
])
.title("AFC")
.subtitle("2024 regular season")
.rowKey("team")
.build();
await prepare(spec);
const table = createTable(spec, STANDINGS, { pageSize: 4, sort: { col: "wins", dir: "desc" } });
const host = document.createElement("div");
host.innerHTML = renderHTML(table); // the same string a server or a static site would send
// one delegated listener set; each change re-renders only the table block and the pager
hydrate(host.querySelector(".sdvt") as Element, table);
host;
hydrate batches its re-render into the next animation frame, so the example above changes one frame after the click
or keystroke: a script that reads the table straight after the event sees the old rows. Your own wiring and useTable
update synchronously.
Inside an interactive table, j and k move between the rows of the page, h and l pick a column, s sorts it, / jumps to
the search box, and Enter or Space selects the row. The arrow keys move like j, k, h and l. With
spec.interactive.hotkeys: false the letters and / are off and only the arrows, Enter and Space are left. Enter and
Space select only when a row itself has focus, not a link or button inside it. keyAction is that mapping as a pure
function, and handleKeydown the listener hydrate and <SdvTable/> attach.
{
"type": "cursor",
"row": 0,
"col": "net_epa"
}import { keyAction } from "@sportsdataverse/sdvtables/html";
keyAction("l", {
focused: 0,
onRow: true,
cursor: { row: 0, col: null },
sorted: "wins",
cols: ["team", "wins", "net_epa"],
order: [0, 1, 2],
hotkeys: true,
});
{
"row": 1,
"col": null
}import { createTable, defineTable } from "@sportsdataverse/sdvtables";
import { handleKeydown, renderHTML } from "@sportsdataverse/sdvtables/html";
const spec = defineTable<{ team: string; wins: number }>()
.columns((c) => [c.text("team"), c.int("wins")])
.build();
const table = createTable(spec, [
{ team: "KC", wins: 15 },
{ team: "BUF", wins: 13 },
]);
const root = document.createElement("div");
root.innerHTML = renderHTML(table, { fonts: false });
root.addEventListener("keydown", (e) => handleKeydown(table, root, e));
root
.querySelector("[data-sdv-body] tr[data-row]")
?.dispatchEvent(new KeyboardEvent("keydown", { key: "j", bubbles: true, cancelable: true }));
table.state.cursor; // { row: 1, col: null }
The sort rules are plain functions too: a header click cycles ascending, descending, unsorted (nextSortDir), and a
missing value sorts last whichever way (withMissingLast, isMissing).
{
"clicks": [
"asc",
"desc",
null
],
"ascending": [
"LV",
"NYJ",
"MIA",
"KC",
"LAC",
"DEN",
"BUF",
"NE"
],
"descending": [
"BUF",
"DEN",
"LAC",
"KC",
"MIA",
"NYJ",
"LV",
"NE"
],
"missing": [
"NE"
],
"numericKinds": [
"num",
"int",
"pct",
"rank",
"delta",
"tally",
"colorPills",
"colorRanks",
"percentileBar"
]
}import { STANDINGS } from "@sportsdataverse/examples/data";
import {
type Comparator,
NUMERIC_KINDS,
isMissing,
nextSortDir,
withMissingLast,
} from "@sportsdataverse/sdvtables";
// New England's net_epa is blank in the sample rows: a missing value, which sorts last in both directions.
const byNumber: Comparator = (a, b) => (a as number) - (b as number);
const order = (dir: "asc" | "desc"): string[] =>
[...STANDINGS].sort((a, b) => withMissingLast(byNumber, dir)(a.net_epa, b.net_epa)).map((r) => r.team);
{
// a header click cycles ascending, descending, unsorted
clicks: [
nextSortDir(null, "net_epa"),
nextSortDir({ col: "net_epa", dir: "asc" }, "net_epa"),
nextSortDir({ col: "net_epa", dir: "desc" }, "net_epa"),
],
ascending: order("asc"),
descending: order("desc"),
missing: STANDINGS.filter((r) => isMissing(r.net_epa)).map((r) => r.team),
// the column kinds that sort as numbers (every other kind sorts as text, or by date)
numericKinds: [...NUMERIC_KINDS],
};
Driven from outside
With .rowKey("team") a row's id is its team, so another view can name rows: setSelection(ids),
setExternalFilter(row => …) (ANDed with the table's own filters) and setHover(id) drive the table, and
subscribe reports select, hover and change. Setting the same selection, or the same filter FUNCTION, again
notifies nobody, so a two-way link between a chart and a table cannot loop. An inline row => … is a new function on
every call, so it does notify.
| KC | West | 15 | 0.063 |
| BUF | East | 13 | 0.190 |
| LAC | West | 11 | 0.101 |
| DEN | West | 10 | 0.108 |
events: select, change; selected: KC, BUF
import { STANDINGS, type Standing } from "@sportsdataverse/examples/data";
import { createTable, defineTable } from "@sportsdataverse/sdvtables";
import { renderHTML } from "@sportsdataverse/sdvtables/html";
// .rowKey("team"): a row's id is its team, so a figure (or a URL) can name rows without knowing their order
const spec = defineTable<Standing>()
.columns((c) => [c.text("team"), c.text("division"), c.int("wins"), c.num("net_epa", { digits: 3 })])
.title("AFC, 2024")
.rowKey("team")
.build();
const table = createTable(spec, STANDINGS, { sort: { col: "wins", dir: "desc" } });
const events: string[] = [];
const stop = table.subscribe((e) => events.push(e.type));
table.setSelection(new Set(["KC", "BUF"])); // a chart's brush picked the two division winners
table.setExternalFilter((r) => r.wins >= 10); // another view keeps the playoff-record teams; ANDed with the table's own
table.setSelection(new Set(["KC", "BUF"])); // the same selection again notifies nobody, so a two-way link cannot loop
stop();
`${renderHTML(table)}<p>events: ${events.join(", ")}; selected: ${[...table.getSelection()].join(", ")}</p>`;
Your own markup and wiring
hydrate is a thin layer over exported parts. handleClick, handleInput and handleHover are the listeners it
attaches, and rowIdAt names the row under an event; renderParts, tableHTML and tableRenderOptions re-render
the table block from the engine's state.
| KC | Patrick Mahomes | 15 | 2 |
| LAC | Justin Herbert | 11 | 6 |
| DEN | Bo Nix | 10 | 7 |
| LV | Gardner Minshew | 4 | 13 |
import { STANDINGS, type Standing } from "@sportsdataverse/examples/data";
import { createTable, defineTable } from "@sportsdataverse/sdvtables";
import {
handleClick,
handleHover,
handleInput,
pagerLabel,
renderInteractive,
renderParts,
rowIdAt,
tableHTML,
tableRenderOptions,
} from "@sportsdataverse/sdvtables/html";
const spec = defineTable<Standing>()
.columns((c) => [c.text("team"), c.text("qb", { label: "Quarterback" }), c.int("wins"), c.int("losses")])
.title("AFC, 2024")
.rowKey("team")
.build();
const table = createTable(spec, STANDINGS, { pageSize: 4 });
const el = document.createElement("div");
el.innerHTML = renderInteractive(table); // what renderHTML(table) returns: toolbar, table block, pager
const status = document.createElement("p");
el.append(status);
// The same three handlers hydrate attaches: each reads the event target and calls the engine.
el.addEventListener("click", (e) => {
const id = rowIdAt(table, e.target); // the row id under the click, or null off the body rows
if (id !== null) status.textContent = `Clicked ${id}`;
handleClick(table, e.target); // a sort button sorts, a pager button pages, a row toggles its selection
});
el.addEventListener("input", (e) => handleInput(table, e.target)); // the search and filter boxes
el.addEventListener("mouseover", (e) => handleHover(table, e.target)); // the engine's hover event
// Re-render the table block and the pager label only, so the search box keeps its focus while you type.
const body = el.querySelector("[data-sdv-body]") as Element;
const label = el.querySelector("[data-sdv-page-label]") as Element;
table.subscribe((e) => {
if (e.type === "hover") return;
body.innerHTML = tableHTML(renderParts(spec, table.rows, { css: "none", ...tableRenderOptions(table) }));
label.textContent = pagerLabel(table);
});
el;
renderParts returns the pieces renderHTML(table) joins, so a page can lay them out its own way: here the pager
sits above the table and the search box below it. hydrate finds the block, the pager and the boxes wherever they sit.
| BUF | East | 525 | 368 |
| DEN | West | 425 | 311 |
| LAC | West | 402 | 301 |
| KC | West | 385 | 326 |
import { STANDINGS, type Standing } from "@sportsdataverse/examples/data";
import { createTable, defineTable } from "@sportsdataverse/sdvtables";
import {
assemble,
attrsText,
hydrate,
renderPager,
renderParts,
renderToolbar,
sortAria,
tableHTML,
tableRenderOptions,
} from "@sportsdataverse/sdvtables/html";
const spec = defineTable<Standing>()
.columns((c) => [
c.text("team"),
c.text("division"),
c.int("pf", { label: "PF" }),
c.int("pa", { label: "PA" }),
])
.title("AFC, 2024")
.theme("swiss")
.rowKey("team")
.build();
const table = createTable(spec, STANDINGS, { pageSize: 4, sort: { col: "pf", dir: "desc" } });
// renderParts gives the pieces renderHTML(table) joins; tableRenderOptions carries the engine's sort, selection, page
const p = renderParts(spec, table.rows, tableRenderOptions(table));
const inner = `${renderPager(table)}<div class="sdvt-body" data-sdv-body="">${tableHTML(p)}</div>${renderToolbar(table, p.labels)}`;
// a card around the wrapper; attrsText escapes the values, as assemble does for the wrapper's own attributes
const card = `<section${attrsText({ class: "standings-card", "data-pf-sort": sortAria(table.state.sort, "pf") })}>${assemble(p, inner)}</section>`;
const host = document.createElement("div");
host.innerHTML = card;
hydrate(host.querySelector(".sdvt") as Element, table); // hydrate finds the block, pager and inputs wherever they sit
host;
React
<SdvTable/> renders the markup renderHTML does, less the fonts <link> (static without interactive), and wires
it with the same handlers. The page's <head> carries the link instead: the one fontsLinkFor(spec) returns. useTable
gives a component the engine and a snapshot that re-renders it on every change.
| Team | Division | Wins | Losses |
|---|---|---|---|
KC | West | 15 | 2 |
LAC | West | 11 | 6 |
DEN | West | 10 | 7 |
LV | West | 4 | 13 |
KC | West | 15 | 2 |
BUF | East | 13 | 4 |
LAC | West | 11 | 6 |
DEN | West | 10 | 7 |
import { STANDINGS, type Standing } from "@sportsdataverse/examples/data";
import { defineTable } from "@sportsdataverse/sdvtables";
import { fontsLinkFor, prepare } from "@sportsdataverse/sdvtables/html";
import { SdvTable } from "@sportsdataverse/sdvtables/react";
import { useEffect } from "react";
const spec = defineTable<Standing>()
.columns((c) => [
c.logo("team", { league: "nfl", includeName: true }),
c.text("division", { filterable: true }),
c.int("wins"),
c.int("losses"),
])
.title("AFC, 2024")
.rowKey("team")
.build();
await prepare(spec); // before the first render, on the server too
function Standings() {
// <SdvTable/> writes no fonts <link>: the page <head> carries the one fontsLinkFor returns (a server puts it there)
useEffect(() => document.head.insertAdjacentHTML("beforeend", fontsLinkFor(spec)), []);
return (
<div style={{ display: "grid", gap: 24 }}>
<SdvTable spec={spec} rows={STANDINGS.filter((r) => r.division === "West")} />
<SdvTable spec={spec} rows={STANDINGS} interactive pageSize={4} sort={{ col: "wins", dir: "desc" }} />
</div>
);
}
<Standings />;
8 of 8 teams, page 1 of 2
| BUF | East | 525 | 368 |
| DEN | West | 425 | 311 |
| LAC | West | 402 | 301 |
| KC | West | 385 | 326 |
import { STANDINGS, type Standing } from "@sportsdataverse/examples/data";
import { defineTable } from "@sportsdataverse/sdvtables";
import { SdvTable, useTable } from "@sportsdataverse/sdvtables/react";
const spec = defineTable<Standing>()
.columns((c) => [c.text("team"), c.text("division", { filterable: true }), c.int("pf"), c.int("pa")])
.title("AFC, 2024")
.rowKey("team")
.build();
function Standings() {
// snapshot re-renders this component on every engine change; table drives <SdvTable table={…}/>
const { table, snapshot } = useTable(spec, STANDINGS, { pageSize: 4, sort: { col: "pf", dir: "desc" } });
const selected = [...table.getSelection()];
return (
<div>
<p>
{snapshot.filteredCount} of {STANDINGS.length} teams, page {snapshot.state.page + 1} of{" "}
{snapshot.pageCount}
{selected.length > 0 ? `; selected: ${selected.join(", ")}` : ""}
</p>
<SdvTable table={table} interactive />
</div>
);
}
<Standings />;
In the gallery: HTML renderer, Interactive tables and React.
KC
LAC
DEN
LV
BUF
MIA
NYJ
NE