Custom
Turning quantitative values into geometric shapes.
AI-assisted, human-approvednovem uses AI to review and keep our documentation up to date.
CSS
Novem custom plots run in a sandboxed iframe so that the plot author
can use any JavaScript library they like without affecting the
surrounding document. The iframe still inherits the parent document's
theme however, with every --novem-* value propagated in two ways: as
CSS variables on the iframe's root, and as a JavaScript object available
to the plot's render function.
The frame has no network connection and receives no viewer cookies or access
to the parent document. Libraries must be supplied through the plot's declared
dependencies or inlined in custom.js; runtime fetch, XHR and arbitrary
remote script loads are blocked. Treat every value in the render payload as
visible to the plot code itself: the sandbox protects the viewer and parent
page, not plot data from its author.
Through CSS
Variables are injected into the iframe's :root before your stylesheet
runs, so you can use them directly anywhere you'd write a colour or a
font.
body {
font-family: var(--novem-font-body);
font-size: var(--novem-font-size);
color: var(--novem-text);
background: var(--novem-bg);
}
.tooltip {
background: var(--novem-tooltip-bg);
color: var(--novem-tooltip-text);
border-radius: var(--novem-tooltip-radius);
}
Through JavaScript
Every variable is also exposed on render.theme as a camelCase value,
which is useful when you build SVG or canvas content from JavaScript.
The categorical palette is an array indexed from zero.
const t = render.theme;
const svg = d3.select(node).append("svg")
.attr("width", width)
.attr("height", height)
.style("font-family", t.fontBody)
.style("background", "transparent");
const color = d3.scaleOrdinal(t.colors); // 10 categorical entries
svg.append("text")
.attr("fill", t.text)
.attr("font-weight", t.fontWeightHeading)
.text("My chart");
const arc = d3.arc()
.innerRadius(parseFloat(t.pieInnerRadius) * r)
.outerRadius(r);
render.theme is dark-mode aware. The value of t.text already
reflects whichever mode is active, so you don't need to branch on
render.dark to pick a foreground colour. The boolean is still there
if you want it for finer distinctions.
Dependencies
custom.deps lists the libraries loaded into the iframe before your
custom.js runs. It takes Novem registry specifiers, one per line — the
value is newline-separated, never comma-separated and never a URL. // starts
a line comment and /* ... */ a block comment, so you can keep notes beside
the list.
/*
* Dependencies for this plot, one per line.
*/
d3@7
@observablehq/plot@0.6
Scoped packages keep their scope (@observablehq/plot@0.6); unscoped ones are
just the name (d3@7). A specifier spelled as a full major.minor.patch is
that exact release; the shorter ones track the newest release Novem has
bundled in that line.
| Specifier | Global |
|---|---|
d3@7 | d3 |
@observablehq/plot@0.6 | Plot |
@observablehq/plot@0.6.16 | Plot |
ramda@0.28.0 | R |
ramda@0.30 | R |
ramda@0.32 | R |
Each entry is exposed as a global under the name in the second column, which is
where the d3 in the examples above comes from. They load in the order you
list them, before custom.js. A specifier that is not in the table does not
load, and its global is undefined when your code runs.
Arbitrary URLs are not supported. A line beginning with https:// is not a
URL here — // is the comment marker, so the line is truncated at it. The
iframe's Content-Security-Policy allows scripts only from the Novem data host,
so a third-party URL could not load in any case. A library that is not in the
table has to be inlined into your custom.js.
Note: See Size limits for the
custom.js limit. Load supported libraries through custom.deps; include
other required code in the script itself.
Loading state
While its dependencies load, a newly created plot frame shows the novem
loader, an element with the id novem-loader placed beside node, so node
itself starts empty. The loader is removed as soon as your custom.js
returns, so a plot that ignores it needs no code.
To coordinate the loader with your chart's entrance animation, add the class
novem-loader-out to the loader while your code runs. The class fades the
loader out, and the frame removes it once the transition or animation on
#novem-loader itself has ended, or after one second, whichever is first.
The render counts as complete once the loader is gone, and PNG, card and
slide exports are captured at that moment, so make the exit last at least as
long as your enter animation.
const loader = document.getElementById("novem-loader");
// ... build the chart ...
if (!render.static && loader && !loader.classList.contains("novem-loader-out")) {
loader.classList.add("novem-loader-out");
bars
.attr("y", y(0))
.attr("height", 0)
.transition()
.delay(150)
.duration(600)
.attr("y", (d) => y(d.value))
.attr("height", (d) => y(0) - y(d.value));
}
The default exit is a 200 ms fade, shorter than these bars' 150 + 600 ms
enter, so lengthen it from custom.css. Keep it under the one-second
fallback:
#novem-loader.novem-loader-out {
opacity: 0;
transform: scale(0.8);
transition: opacity 750ms ease-out, transform 750ms ease-out;
}
The loader exists only in a freshly created frame. It is absent when
render.static is true, and gone once your first draw has finished with it,
so always check for null.