Skip to main content

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.

AFC, 2024
TeamQBWinsLosses
3139477Patrick Mahomes152
4038941Justin Herbert116
4426338Bo Nix107
4038524Gardner Minshew413
3918298Josh Allen134
4241479Tua Tagovailoa89
8439Aaron Rodgers512
4431452Drake Maye413
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).

AFC West, 2024
TeamWinsLosses
152
116
107
413
AFC East, 2024
TeamWinsLosses
134
89
512
413
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&amp;family=Work+Sans:wght@500;650&amp;display=swap">

AFC, 2024
TeamWinsLosses
152
116
107
413
134
89
512
413
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, "&amp;").replace(/</g, "&lt;");
`<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).

AFC, 2024
TeamQbWinsLosses
Patrick Mahomes152
Justin Herbert116
Bo Nix107
Gardner Minshew413
Josh Allen134
Tua Tagovailoa89
Aaron Rodgers512
Drake Maye413
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.

AFC2024 regular season
West152385326
East134525368
West116402301
West107425311
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.

AFC, 2024
KCWest150.063
BUFEast130.190
LACWest110.101
DENWest100.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.

AFC, 2024
KCPatrick Mahomes152
LACJustin Herbert116
DENBo Nix107
LVGardner Minshew413

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.

AFC, 2024
BUFEast525368
DENWest425311
LACWest402301
KCWest385326
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.

AFC, 2024
TeamDivisionWinsLosses
West152
West116
West107
West413
AFC, 2024
West152
East134
West116
West107
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

AFC, 2024
BUFEast525368
DENWest425311
LACWest402301
KCWest385326
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.