Skip to content

Theming & styling

A Theme is just a value object — a plain record of colors, sizes, durations, and behavior flags. The chart keeps it in a reactive signal, so swapping themeDefault for themeMinimalGrid (or a fully custom theme) re-renders live without touching your data or mark layers. Because themes are values, they compose cleanly: the theme() factory deep-merges a partial override into any base, so you only touch what you mean to change.

Two built-in themes ship out of the box:

ExportBackgroundTextGood for
themeDefaultNear-black rgba(0.02, 0.04, 0.07, 1)LightDashboards, dark UIs
themeMinimalGridWhiteDarkReports, light UIs, print

Apply a theme in whichever way fits your flow:

import { plot, theme, themeDefault, themeMinimalGrid } from "insomni-plot";
import { rgba } from "insomni";
// 1. Pass it in the spec
plot({ data, theme: themeMinimalGrid }).layer(/* … */);
// 2. Builder method — returns a new Chart, original is unchanged
plot({ data }).theme(themeMinimalGrid).layer(/* … */);
// 3. Deep-merge a partial override onto a base
const myTheme = theme(
{
marks: { strokeWidth: 2, barCornerRadius: 3 },
axis: { gridColor: rgba(0, 0, 0, 0.05) },
},
themeMinimalGrid, // base; defaults to themeDefault when omitted
);
plot({ data, theme: myTheme }).layer(/* … */);

The Theme interface has 14 top-level fields. Most are sub-objects you’ll partially override rather than replace wholesale.

Color — the panel fill used when the chart paints its own background. The accessibility system uses this as the reference background when computing contrast for axis labels and legends.

FieldTypeDefault (themeDefault)
colorColorrgba(0.85, 0.9, 0.98, 1)
fontFamilystring"system-ui, -apple-system, Inter, sans-serif"

Base style for all non-title/subtitle text that doesn’t have its own theme slot. Override font-family here to retheme everything at once.

FieldTypeDefault (themeDefault)
title.fontSizenumber14
title.fontWeightstring"600"
title.colorColornear-white
subtitle.fontSizenumber14
subtitle.colorColormuted near-white

Override on the chart: theme({ title: { fontSize: 16, fontWeight: "700" } }).

FieldTypeNotes
colorColorSpine and tick color
gridColorColorDefault gridline color
labelFontSizenumberTick label font size (px)
labelColorColorTick label color
titleFontSizenumberAxis title font size (px)
titleColorColorAxis title color

Per-axis visual overrides (gridline style, placement, etc.) go through the .axes() builder — see § Axis visual overrides and Axes & coordinates.

FieldTypeDefault
fontSizenumber14
labelColorColorLight near-white
swatchGapnumber6
entryGapnumber14

The most-used sub-object — geoms read these as their per-layer defaults before any geom-level option overrides kick in.

FieldTypeDefaultConsumed by
pointRadiusnumber3point, connectedScatter
pointStrokeColor | undefinedrgba(1,1,1,0.18)point
pointStrokeWidthnumber0.75point
strokeWidthnumber1.5line, smooth, statRolling, area outline, aggregate
barCornerRadiusnumber1bar, histogram
fillAlphanumber0.85bar, area, violin, boxplot
labelFontSizenumber14bar value labels, text geom
ruleStrokeWidthnumber1rule
ruleLabelInsetnumber4rule annotations
annotationFontSizenumber14rule, band labels
ribbonFillAlphanumber0.18ribbon
bandFillAlphanumber0.08band
sizeRange[number, number][2, 14]size channel pixel range
alphaRange[number, number][0.2, 1]alpha channel range

The default color palettes the chart uses when no explicit scale palette is set. Three slots: categorical (defaults to category10), continuous (defaults to viridis), and diverging. Override any or all:

import { theme, themeDefault } from "insomni-plot";
import { viridis, tableau10 } from "insomni-plot/core";
const t = theme({ palettes: { categorical: tableau10, continuous: viridis } }, themeDefault);

Semantic colors for chart-level annotations — reference regions, threshold rules, callouts:

FieldSuggested use
positiveUptrend lines, positive bands
negativeDowntrend lines, alert bands
warnWarning thresholds
infoInformational references

accessibility, textEffects, paletteBlendSpace

Section titled “accessibility, textEffects, paletteBlendSpace”

Covered in dedicated sections below.

Covered in Motion & interaction tokens.


All continuous palettes are sampled as palette(t) where t ∈ [0, 1].

ExportKindCharacter
viridisSequentialPurple → green → yellow
plasmaSequentialPurple → orange → yellow
infernoSequentialBlack → red → yellow-white
magmaSequentialBlack → purple → cream
cividisSequentialNavy → blue-grey → yellow (CVD-safe)
blues, greens, oranges, reds, purples, greysSequential single-hueColorBrewer
coolwarmDivergingBlue → grey → red
rdbuDivergingRed → white → blue
spectralDivergingRed → yellow → blue
brbg, prgn, piyg, puorDivergingColorBrewer

Build your own: continuousPalette(hexStops) accepts an array of hex strings and returns a sampler with the same (t) => Color interface.

ExportSizeSource
category1010D3 / Observable (chart default)
tableau1010Tableau
set1, set2, set39 / 8 / 12ColorBrewer
dark28ColorBrewer
accent8ColorBrewer
paired12ColorBrewer (12 paired hues)
pastel8

Build your own: categoricalPalette(hexColors) takes a string array and returns a categorical palette compatible with the color scale.

When you need a palette as a callable color function outside of insomni-plot’s scale machinery, colorScale wraps it:

import { colorScale, viridis, tableau10 } from "insomni-plot/core";
// Continuous: map a numeric domain to colors
const heat = colorScale(viridis, [0, 100]);
heat(42); // → Color
// Categorical: map string keys to colors
const byCategory = colorScale(tableau10, ["low", "med", "high"]);
byCategory("med"); // → Color

theme.paletteBlendSpace: BlendSpace controls how the chart interpolates continuous palette stops when building color scales and color bars. The default is "oklch" — perceptually uniform with hue preserved along the gradient. Fall back to "srgb" for Web-standard interpolation:

theme({ paletteBlendSpace: "srgb" }, themeDefault);

To pin a single palette’s blend space independently of the theme, use palette.withBlendSpace("oklch").


Pass a Gradient as a geom’s fill to paint a screen-space gradient rather than a flat color or a per-datum color scale. Useful for area charts and large filled shapes where a continuous hue ramp adds depth without encoding data.

import { plot, area, linearGradient } from "insomni-plot";
import { rgba } from "insomni";
const grad = linearGradient(
[
{ offset: 0, color: rgba(0.3, 0.6, 1, 0.9) },
{ offset: 1, color: rgba(0.3, 0.6, 1, 0.05) },
],
{ along: "y" }, // "x" | "y", default "y"
);
plot({ data: series })
.layer(area({ x: (d) => d.date, y: (d) => d.value, fill: grad }))
.mount(canvas, { device });

linearGradient(stops, options?) — requires ≥ 2 stops. Each stop is { offset: number; color: Color } where offset is in [0, 1]. The along option defaults to "y" (top → bottom).

isGradient(x) is a type-guard you can use when building geom wrappers that accept either a Color or a Gradient.


The chart applies a readability policy at every text draw site that knows its local background — axis labels over the panel, legend labels over the background, tile labels over their cell color. The defaults are intentionally strict:

export const DEFAULT_ACCESSIBILITY: Accessibility = {
enabled: true,
metric: "apca", // "apca" | "wcag"
wcagLevel: 7, // contrast ratio floor; only used in wcag mode
apcaTarget: "auto", // |Lc| floor or "auto" (font-size lookup)
mode: "auto-color", // "auto-color" | "outline" | "shadow" | "none"
warn: true, // console.warn when a color fails and gets fixed
equalize: true, // normalize mark labels to the same contrast level
themeBias: 0.5, // blend toward nearest passing theme accent on fix
blendSpace: "oklch",
};

metric — APCA ("apca") models perceived contrast better than WCAG for modern displays and is the default. Switch to "wcag" if your organization has a hard WCAG ratio requirement.

equalize — when true, tile and heatmap labels are normalized to the same contrast floor across all cells (the worst cell’s reachable ceiling becomes the shared floor). The internal helper is layerEqualizeTarget(theme, backgrounds) — useful if you’re building a custom geom.

mode — what to do when a color fails:

  • "auto-color" — shift the color toward the nearest passing accent (default)
  • "outline" / "shadow"TextEffects stub; falls back to auto-color with a one-time console warning (not yet rendered by the SDF text path)
  • "none" — pass the original color through unchanged

Override per chart:

theme({ accessibility: { metric: "wcag", wcagLevel: 4.5, warn: false } });

Per-call overrides go through the .axes() builder and apply on top of the theme’s axis sub-object. See Axes & coordinates for the full tick / domain / coordinate-system API.

AxisSpec fieldTypeNotes
gridLinesbooleanShow or hide gridlines
gridDash"solid" | "dashed" | "dotted" | number[]Default "solid"
gridColorColorOverrides theme.axis.gridColor on this axis only
gridWidthnumberGridline width (px)
axisLinebooleanShow or hide the axis spine (default true)
placement"outside" | "inside"Labels inside the plot frame
labelAnchor"on-line" | "above-line" | "below-line"Vertical label position for inside placement
side"left" | "right"Which side a y-axis renders on

fullBleed + inside placementfullBleed removes the gutter on a given edge but does not hide the axis. Pair with placement: "inside" to keep labels readable over the marks:

plot({ data })
.fullBleed({ left: true, right: true })
.axes({ x: { placement: "inside", gridDash: "dashed" } });

The chart’s outer geometry is controlled by three independent knobs:

APIWhereDefaultEffect
padding builder.padding(spec)0Outer inset applied before axes and legend are reserved
framePadding spec fieldplot({ framePadding })0Inner slack between the axis lines and the data viewport
fullBleed builder.fullBleed(spec)falseBypasses axis-gutter reservation on the given edges

DEFAULT_PADDING = 0 — the plot fills its canvas by default with no outer margin. Add padding when you need breathing room around the whole chart, or framePadding when you want the axis lines to sit away from the data without shrinking the outer container.

Both padding and framePadding accept a uniform number or a per-side object { top?, right?, bottom?, left? }. fullBleed accepts true (all edges) or the same per-side object.


Shape, border-style & overlay-glyph palettes

Section titled “Shape, border-style & overlay-glyph palettes”

When point or connectedScatter map a categorical channel to shape, borderStyle, or overlayGlyph, these are the default orderings. Override any of them via .scale(channel, { palette: [...] }).

POINT_SHAPE_PALETTE — ordered by silhouette distinctness:

"circle", "triangle", "square", "diamond", "plus", "cross", "star",
"circle-open", "triangle-open", "square-open", "diamond-open"

DEFAULT_BORDER_STYLE_PALETTE:

["solid", "open", "dashed", "dotted"];

PointBorderStyle = "solid" | "dashed" | "dotted" | "open".

DEFAULT_OVERLAY_GLYPH_PALETTE:

[null, "plus", "cross", "star", "diamond"];

The first slot is null — the most-common category gets no overlay, which reduces visual clutter in the majority case.

distinguishable — co-optimized color + shape

Section titled “distinguishable — co-optimized color + shape”

When you’re multi-encoding (same variable on both color and shape), use palettes.distinguishable to get palettes that are co-optimized for the same number of categories:

import { plot, point, palettes } from "insomni-plot";
const { color, shape } = palettes.distinguishable({
for: { color: 5, shape: 5 },
scheme: "tableau10", // optional; defaults to "category10"
});
plot({ data: rows })
.layer(point({ x: (d) => d.x, y: (d) => d.y, color: (d) => d.species, shape: (d) => d.species }))
.scale("color", { palette: color })
.scale("shape", { palette: shape })
.legend({ merge: ["color", "shape"] })
.mount(canvas, { device });

Available scheme values: "category10", "tableau10", "set1", "set2", "set3", "dark2", "paired", "pastel".


theme.motion is the master motion budget. Geoms and interactions read named durations and easings rather than hard-coded milliseconds — a single theme swap retunes the whole chart’s feel.

FieldTypeDefault
enabledbooleantrue
duration.fastnumber (ms)120
duration.basenumber (ms)240
duration.slownumber (ms)480
easing.standardEasingease-in-out cubic
easing.emphasizedEasingease-out cubic
easing.decelerateEasingease-out quad
easing.linearEasinglinear
dataThemeMotionChannel{ duration: "base", easing: "emphasized" }
axisThemeMotionChannel{ duration: "base", easing: "standard" }
tooltip.showDelaynumber (ms)
tooltip.hideDelaynumber (ms)
tooltip.fadeMsnumber (ms)
tooltip.settleDelaynumber (ms)

motion.enabled is the master kill switch for animation. Set it to false to suppress all transitions. To respect the OS-level preference, read the standard media query yourself and pass the result into the theme:

import { plot, theme } from "insomni-plot";
// Respect the OS-level setting with the standard media query:
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const chart = plot({ data }).theme(theme({ motion: { enabled: reduce ? false : true } }));

theme.interactions controls what happens visually when a row is hovered or selected.

hover and selection are each a ThemeInteractionEmphasis:

FieldTypeNotes
enabledbooleanMaster switch; false skips halo and dim
dimnumberMultiplier on non-active rows’ fill alpha (1 = no dim)
haloStrokeWidthnumberHalo ring width (CSS px); 0 skips the halo
haloColorColor | undefinedOmit to auto-pick a high-contrast foreground
durationMsnumber | undefinedDim animation duration; <= 0 snaps

hoverSwapGraceMs (default 50) — coalesces a hover-exit → hover-enter pair within this window so adjacent marks don’t flash an un-dim/re-dim cycle.

tooltipAccents — semantic colors for tooltip rows using the accent: "positive" | "negative" shorthand:

theme({
interactions: {
hover: { dim: 0.3, haloStrokeWidth: 2 },
tooltipAccents: {
positive: rgba(0.1, 0.75, 0.4, 1),
negative: rgba(0.9, 0.25, 0.25, 1),
},
},
});