Skip to content

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.

SpecifierGlobal
d3@7d3
@observablehq/plot@0.6Plot
@observablehq/plot@0.6.16Plot
ramda@0.28.0R
ramda@0.30R
ramda@0.32R

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.