Skip to main content
Everything the chart draws (geoms, grid lines, tick marks, the panel border, the graph background, axis, tick and data-label type) is painted from a stylesheet on the spec. You add one by piping a styles(...) part in, the same way you pipe config(...).
Because it lives on the spec, a stylesheet is serializable and travels with the chart, like mappings, scales and config.
Theme tokens (the built-in light/dark chrome, selected by colorScheme) are a separate surface covering the HTML chrome around the plot. See Theming.

The four keys

StyleRule[]
Applied where no mapped aesthetic decided the value: the look when nothing else speaks.
StyleRule[]
Applied over whatever a mapping decided.
Record<string, StyleTokenValue>
Named colors that entries reference with token('name').
Stylesheet[]
Composes other stylesheets underneath this one. Tokens merge name by name, lists concatenate, later entries decide. This is how a house style ships as a reusable preset.
Piping several styles(...) parts stacks them in order, each above everything piped before it. Within a single list, order is specificity: the last matching entry that declares a property decides.

How a value is resolved

Each property resolves through three tiers, in order: So defaults never fight your mappings, and overrides always do. To recolour a group that is mapped to color you need an overrides entry; a defaults entry loses to the scale. Entries scoped to a state ({ state: 'hovered' | 'dimmed' }) sit above the whole stateless cascade.

Targets

Call style.<target>(declarations, options?). Geom targets take conditions (where, state, layer). Annotation targets take { annotation: id }. Chrome targets take declarations only. Every entry may also carry a coord. Notes on the vocabulary:
  • A bar’s cornerRadius is a token, 'none' | 'xs' | 'sm' | 'md' | 'lg' | 'xl' | 'full', defaulting to 'sm', or a number of pixels: style.geom.bar({ cornerRadius: 3 }). A box rounds with cornerRadius in pixels, one number or one per corner: dataLabel, the tooltip, a legend pill, a text or image annotation and a callout label take style.tooltip({ cornerRadius: { topLeft: 0, topRight: 8, bottomRight: 8, bottomLeft: 8 } }); panelBorder, style.graph and the edit outline take one number.
  • A box is painted with fill, and its border with stroke and strokeWidth: the graph frame, the tooltip, a data label, a legend pill, a text annotation and a callout label all take the same words.
  • style.graph({ fontFamily }) is the chart’s base family: every text region without a family of its own follows it, in paint and in measurement. fontSize, fontWeight, lineHeight and textColor on graph are the same type words; the content targets declare their own.
  • style.heading, style.heading.h1 and style.heading.h2 are the title and the subtitle. A bare heading entry addresses both levels. Color, family and size on rich-text runs paint over this base type. style.caption and style.source are the footer type; style.source.link is the URL.
  • style.graph({ textScale }) multiplies every text size at resolve time. 1 is the built-in; a preset can set a house scale through extends. gap, offset, margin, padding and stroke widths do not scale.
  • style.graph({ padding }) is the outer frame, in pixels, and does not follow textScale. A number applies to every side; an object sets sides, and omitted edges keep the builtin 24.
  • margin is air around a chrome target that occupies a layout slot (header, footer, legend, headline, axisLabel, tickLabel). A number sets every side; omitted object sides stay absent, so they do not overwrite earlier entries. 0 closes a side. gap is the space between items inside a chrome target. See Layout. The built-in tick offset is 10, left and right 14.
  • Every geom takes a shadow ('none' or { offsetX, offsetY, blur, color }) and a blendMode ('normal' | 'multiply' | 'screen' | 'overlay' | 'darken' | 'lighten'); so does style.graph for the frame. A line, an area and the polar kinds read both once per layer, from the first observation. The built-in hovered state lifts the geom under the pointer with a soft shadow, bars with a larger one. style.geom.point({ shadow: 'none' }, { state: 'hovered' }) drops it.
  • Every text target takes the same type words: fontFamily, fontSize, fontWeight, fontStyle ('normal' | 'italic' | 'oblique'), letterSpacing (pixels, negative allowed), textTransform ('none' | 'uppercase' | 'lowercase' | 'capitalize'), textDecoration ('none' | 'underline' | 'line-through'), textOutlineColor and textOutlineWidth (the width visible outside the glyphs), textShadow (the geom shadow value), lineHeight and textColor. Layout measures the string with its case and letter spacing applied, so a label reserves the space its glyphs take. Tick labels carry a built-in outline in the graph background, so a label stays legible where it crosses a grid line; style.tickLabel({ textOutlineWidth: 0 }) removes it.
  • A line, an area and a rule take lineCap ('butt' | 'round' | 'square') and lineJoin ('miter' | 'round' | 'bevel'). None has a built-in: a path keeps its rounded caps until an entry says otherwise.
  • Every geom takes blur (pixels) and brightness, contrast and saturation (multiples, 1 unchanged). A filter declared without a state draws on the geom itself. A filter declared with { state: 'dimmed' } draws on the whole layer over the geoms already painted: style.geom({ blur: 1, saturation: 0.3 }, { state: 'dimmed' }) blurs and grays a dimmed layer.
  • fill is a paint, not only a color. Beside a color, a token or a light-dark pair it takes:
    • a gradient: { gradient: 'linear', angle?, stops: [{ offset, color }, …] } or { gradient: 'radial', stops }, with at least two stops;
    • a pattern: { pattern: 'diagonal' | 'dots' | 'crosshatch' | 'lines', color, background?, size? }, repeating every size screen pixels. lines draws one level line across the top third of each tile;
    • an image: { image, fit?, alpha?, size?, fallback }, a data URI only. fit: 'tile', the default, repeats it every size screen pixels wide at its own proportions; fit: 'stretch' fills the shape with one copy. fallback always paints behind the image; alpha affects only the image.
    Every color inside takes tokens. stroke stays a color. Bars, tiles, polar bars, polar areas, the graph’s frame and HTML boxes draw the paint. The swatches of bars, tiles and polar bars draw it too when it covers the swatch’s whole color group: declared for the layer, or by a where that reads only the color variable. A where on any other variable picks out observations, so it stays off the swatch. Everywhere else only one color can be drawn (a contrast pick, a hover marker, a line’s wash, a cartesian area, a point, any other swatch), so it is picked from the paint: a gradient’s first stop, a pattern’s color, an image’s fallback.
  • Every geom and style.graph take an overlay: a paint drawn over what the target already drew. It takes what fill takes, a list of those with the first on top, or 'none':
    Use fallback: 'transparent' to keep the chart’s colors visible beneath an image overlay. An opaque fallback covers them even when the image’s alpha is low. A geom’s overlay is drawn over everything its layer painted and nowhere else, a geom from a package included. It is as strong as the paint under it: over a bar whose fillAlpha is 0.3 it is drawn at 0.3 too. It is drawn for the layer as one. A where entry is decided by the layer’s first observation, which the compiler warns about, and { state: 'hovered' } sets the overlay of the shape under the pointer. A marker drawn on the layer for a hover elsewhere keeps the layer’s resting overlay. The graph’s overlay does not reach the tooltip, which is a box of its own. A geom’s overlay is painted through a mask of the layer, so the browser draws that layer twice. On a layer with many thousands of shapes, the graph overlay costs less than a geom overlay. Geom overlays support SVG renderers only. A portal-rendered geom ignores a non-empty overlay and reports INVALID_STYLE_RULE. An overlay declared with { state: 'dimmed' } also reports INVALID_STYLE_RULE and is ignored; other properties in the entry still apply, and the resting overlay fades with the layer.
  • Every box takes a shadow and an alpha beside its fill: a data label, the tooltip, a legend pill and the legend popover, a text or image annotation, a callout or difference-arrow label, and style.graph for the frame. style.tooltip({ shadow: { offsetX: 0, offsetY: 4, blur: 12, color: 'rgba(0, 0, 0, 0.2)' }, alpha: 0.95 }) lifts and fades the popover; the frame’s alpha thins its background alone, so the plot keeps its opacity.
  • A point takes a symbol ('circle' | 'square' | 'diamond' | 'triangle' | 'cross' | 'star' | 'wye'), built in circle. Every symbol covers the area of the circle its size names, so style.geom.point({ symbol: 'diamond' }) keeps the layer’s weight; the hover marker, the highlight dot and the swatch draw the same symbol.
  • Hide a panel-border edge with strokeWidth: 0. The built-in gridLine.x and tickLine entries use the same strokeWidth: 0, so vertical grid lines and tick marks appear only once you give them a width (and tick marks a length).
  • A shape annotation’s border wears its fill unless stroke is declared; strokeWidth: 0 draws none, and fill: 'transparent' leaves a stroke-only outline. { annotation: id } addresses one annotation.
  • An arrow annotation’s sticker look is its own part, style.annotation.arrow.outline({ stroke, strokeWidth, shadow }): an outline around the line and a drop shadow, both off by default.
  • A difference arrow is two parts: the line (strokeWidth also sizes the route around the observations) and its label, style.annotation.differenceArrow.label. A color no entry names falls to the group color both ends share; the label’s box border takes the arrow’s color unless stroke is declared.
  • A text annotation’s fill is its box; with none declared there is no box. The content’s own marks paint over the base type. An image’s cornerRadius clips its corners, in pixels.
  • A pinned number and a comment are a marker dot plus the label bubble beside it (.label). A marker whose fill no entry names borrows its observation’s color; the swatch inside a pinned-number bubble always keeps it.
  • Stack totals (.aggregate) always sit outside the geom, so they take no .inside / .outside.
  • style.headlineItem({ gap }) is the space between a card’s rows; everything else on a card is a part. .number.center is the donut-hole figure; without it the hole uses .number. gap on style.headline, style.headlineItem, style.legend and style.legendItem is pixels and does not follow textScale. A bare style.legend entry takes gap, margin and focusStroke; the overflow popover box is style.legend.popover (fill, stroke, strokeWidth, cornerRadius, paddingInline, paddingBlock, shadow, alpha).
  • Swatch opacity and bar corner radius follow the layer geom, including size-legend bubbles; style.legendItem.swatch and style.headlineItem.swatch only size the square box.

Colors

Any color-valued property takes one of three forms:
{ light, dark } and token(...) resolve against the provider’s colorScheme. Prefer them to literals, which look the same in both schemes.

Conditions

Geom entries take a where predicate (the same language as highlights), a state, and a layer:
layer scopes an entry to one authored layer id, which is how you style a single layer of a combo chart without touching the others. Every entry, chrome included, takes a coord, which keeps it to graphs drawn in that coordinate system. This stylesheet rules off the left edge of a bar graph and leaves a pie without one:
coord is 'cartesian' or 'polar'; a flipped graph is cartesian.

Re-skinning through tokens

The engine’s built-in stylesheet sits behind every chart, and its defaults are written in terms of tokens. Redefining a built-in token name restyles the default it backs, with no entries at all:
The built-in defaults these produce: bars cornerRadius: 'sm' with a 1px border; lines and areas strokeWidth: 2, dashArray: []; areas fillAlpha: 0.3; points size: 8 with a 1px outline; tiles cornerRadius: 8 and no border; rules dashed at 1px; the y grid lines dashed, the x grid lines and tick lines strokeWidth: 0; the panel border dashed with cornerRadius: 6; the graph frame with cornerRadius: 8 and padding: 24; tick labels offset 10px; the dimmed state alpha: 0.4.

Presets with extends

extends composes stylesheets, which is how a house style becomes reusable:

Two token namespaces

textPrimary and textSecondary name both a stylesheet token and a theme token. They are different values in different namespaces: the stylesheet tokens above drive the plot, the theme tokens of the same name drive the chrome around it.

When an entry is invalid

An entry the engine cannot use is reported as an INVALID_STYLE_RULE warning and skipped; the chart still renders. Compile a spec headlessly to see warnings before wiring it into React.

Custom geoms

A custom geom renderer reads paint through the value accessors on its render input (getColor, getAlpha, getSize, …), which expose the data tier. To resolve the full cascade, read styleReaders on the render input (or useGeomStyleReader(layer)):
The render input already carries the graph’s color scheme.
A geom that paints more than one part declares each one, and the kit adds a builder for it: kit.style.geom.lollipop.stem({ stroke: token('brand') }). See what the geom paints.