Semiotic's theme layer owns four color dimensions. Three you already know — primary, categorical,sequential, diverging. The fourth is a set of semantic status roles: named colors for success, danger, warning, error, and info that any chart can pick up through the same theme plumbing.
The goal is one vocabulary for designers. Instead of passing a custom color per chart, put the color on the theme once and every chart that needs "danger" gets the same red — in the same mode, overridable in the same way.
The four theme dimensions
Every SemioticTheme declares colors with four complementary kinds of value:
- Semantic scalars — single colors per semantic meaning:
primary, secondary,success, danger, warning,error, info, plus UI roles liketext, border, grid,surface. - Categorical scale — array of distinct colors for unordered category encodings (
colors.categorical). - Sequential scale — named d3-scale-chromatic scheme for magnitude encodings: heatmap, choropleth, size (
colors.sequential). - Diverging scale — named d3-scale-chromatic scheme for midpoint encodings: likert, bivariate, ± deviation (
colors.diverging).
A designer customizes any of the four. An engineer doesn't choose per-chart colors for status; they just set the role on the theme and every chart downstream picks it up.
Semantic role vocabulary
Every preset defines these scalars. Use them when a color carries meaning beyond "n-th category."
Primary--semiotic-primary
Secondary--semiotic-secondary
Success--semiotic-success
Warning--semiotic-warning
Text secondary--semiotic-text-secondary
Surface--semiotic-surface
When to reach for which role
- primary / secondary — Default fill or stroke when the chart has no color encoding. Also the accent color for interactive affordances like the linked-hover highlight.
- success, danger, warning, error, info — Status semantics. Use on waterfall positive/negative, swimlane state badges, alert-style annotations, threshold reference lines. The rule of thumb: if a non-designer would call it a "color for meaning," it belongs here.
- text / textSecondary — Axis labels, tick text, legend items, annotation text. textSecondary is lower emphasis — tick labels vs axis titles.
- border / grid — Chart chrome. Bar outlines, axis lines, gridlines, legend separators.
- surface — Elevated background (tooltip background, card backing, annotation callouts), distinct from the chart's main
background.
Using roles on charts
Any chart prop that takes a color accepts a CSS variable. Passstroke="var(--semiotic-border)" on aRealtimeHistogram and the stroke resolves to the active theme's border color — in light mode, dark mode, or any brand preset.
JSX
<RealtimeHistogram data={events} binSize={60_000} timeAccessor="timestamp" valueAccessor="value" categoryAccessor="category" colors={{ errors: "var(--semiotic-danger)", // Inherited from theme warnings: "var(--semiotic-warning)", // Inherited from theme }} stroke="var(--semiotic-border)" // Separates stacked segments strokeWidth={1} />
Because the category fills are CSS variables, flipping the ambient theme (light → dark) changes all three colors at once — no per-chart override required. The bars even look correct against every preset's background because --semiotic-border is defined relative to --semiotic-bg within each preset.
CSS cascade override
The theme provider emits one CSS custom property per semantic role (--semiotic-success, --semiotic-danger, etc.) at its mount root. Any descendant DOM node can override a single role for its subtree without replacing the whole theme.
Use case: "the charts in this compliance dashboard have a brand-specific red for 'critical' that's different from the rest of the app." You don't re-theme the app — you cascade-override the role.
Default theme
Scoped override (purple)
JSX
{/* Anywhere in the component tree, scoped to this subtree */} <div style={{ "--semiotic-danger": "#4b0082" }}> <BarChart color="var(--semiotic-danger)" ... /> <Waterfall ... /> {/* Also picks up the override */} </div>
The chart renders on <canvas>, but the CSS cascade still reaches it — Semiotic reads each CSS variable viagetComputedStyle on the canvas's DOM ancestor at paint time. The override is a zero-JS change: no ThemeProviderrequired, no prop drilling, standard cascade rules apply.
Overriding scales (nested ThemeProvider)
CSS custom properties handle scalars cleanly but don't ergonomically carry arrays like the categorical palette or sequential scheme name. For scale overrides — swapping the palette or scheme for a subtree — nest a ThemeProvider with just the values you want to change.
JSX
<ThemeProvider theme="light"> {/* All charts below here use the light theme's categorical palette */} <ThemeProvider theme={{ colors: { categorical: ["#5a189a", "#9d4edd", "#c77dff"], }, }}> {/* Charts in here use the purple palette for categories, but inherit everything else from the parent light theme */} <BarChart colorBy="region" ... /> </ThemeProvider> </ThemeProvider>
The inner provider shallow-merges onto the outer one — anything unspecified is inherited. Scoping is by React subtree (not CSS cascade), so the nested override only applies to descendants, not siblings.
When to use which override
- Scalar role (single color) → CSS custom property on any DOM ancestor:
<div style={{ "--semiotic-danger": "#c00" }}>. - Palette or scale (array / named scheme) → nested
ThemeProvider with a partial theme. - Whole theme (dark mode for one section) → nested
ThemeProvider with a full preset.
Status semantics — worked examples
Waterfall ±
Waterfall bars are intrinsically status-coded: positive = gain, negative = loss. Set the colors once on the theme and every waterfall in the app inherits the same semantics.
JSX
{/* Theme definition — done once for the whole app */} <ThemeProvider theme={{ ...LIGHT_THEME, colors: { ...LIGHT_THEME.colors, success: "#0b8457", // Gains danger: "#c23030", // Losses }, }}> {/* Every waterfall downstream inherits both colors */} <Waterfall data={quarterly} categoryAccessor="quarter" valueAccessor="delta" positiveColor="var(--semiotic-success)" negativeColor="var(--semiotic-danger)" /> </ThemeProvider>
Stacked bars with status-driven categories
A stacked bar chart where the stack layers are statuses (errors / warnings / info) can be entirely theme-driven:
JSX
<StackedBarChart data={deployments} categoryAccessor="team" stackBy="status" valueAccessor="count" colorScheme={[ "var(--semiotic-success)", "var(--semiotic-warning)", "var(--semiotic-error)", ]} />
Flip the app to dark mode, swap to the Carbon theme, or have a compliance dashboard tweak a single role via CSS — the chart updates correctly in every case without code changes.
Primitive styling props
Every shape-drawing chart accepts four primitive styling props at the top level:color, stroke, strokeWidth,opacity. They apply to whatever shape the chart draws — bars, circles, lines, wedges, rects — so a designer uses one vocabulary regardless of chart type.
The lens: "all bars are bars, all circles are circles." A designer shouldn't have to learnpieceStyle vs. pointStyle vs.lineStyle vs. nodeStyle just to put a 1-pixel border on a shape. These four props are the designer-facing API; the *Style function-form props remain as the power-user escape hatch for per-datum customization.
JSX
{/* BarChart */} <BarChart data={monthlyRevenue} categoryAccessor="month" valueAccessor="total" stroke="var(--semiotic-border)" strokeWidth={1} /> {/* Scatterplot — same props, same effect */} <Scatterplot data={points} xAccessor="x" yAccessor="y" stroke="var(--semiotic-border)" strokeWidth={1} /> {/* LineChart — same props again */} <LineChart data={series} xAccessor="x" yAccessor="y" stroke="var(--semiotic-danger)" strokeWidth={3} />
Precedence
When the same style field could come from multiple sources, the resolution order is:
- Top-level primitive prop (e.g.
stroke="red") — the designer-facing knob, wins over everything else for the specific field it sets. - User-supplied
frameProps.*Style function return — the power-user escape hatch for per-datum styling. Still wins over HOC base defaults, only shadowed by the top-level prop. - HOC base style — categorical color resolution, theme fallbacks, chart-specific defaults (like LineChart's
lineWidth). - Theme semantic / categorical / sequential / divergingfallbacks, in that order.
- Hardcoded hex — the ultimate fallback when no theme and no user color are present.
Rationale for "top-level wins over function": explicit stroke="red" is the broad stroke. The per-datum function handles exceptions. When both are set, the designer's global choice should win by default; the user can always override per-datum inside the function if they want granular control.
When to reach for which primitive
color — Uniform fill color for all data marks (only applies when no colorBy is set). Overrides theme categorical and colorScheme.stroke — Uniform stroke color. Accepts a CSS variable; CSS cascade works. Common use: bar separator strokes, scatter point outlines, line charts.strokeWidth — Uniform stroke thickness in pixels. For LineChart, wins over the legacylineWidth prop when both are set.opacity — Uniform opacity (0–1) on all marks. Distinct from--semiotic-selection-opacity, which only dims non-selected marks during linked hover / brush.
For per-datum / per-category customization (e.g. conditional stroke widths or hatch patterns on specific bars), use the function-formframeProps.pieceStyle /frameProps.pointStyle / etc. The top-level prop and the function compose: the function runs first, then the top-level prop overlays last for whatever fields it sets.
CSS variable reference
Every scalar role emitted by ThemeProvider. All are overridable by setting the same-named property on any ancestor DOM node.
| CSS variable | Theme field | Typical use |
|---|
--semiotic-primary | colors.primary | Default fill/stroke; accent |
--semiotic-secondary | colors.secondary | Secondary accent; often neutral gray |
--semiotic-success | colors.success | Positive status; gains |
--semiotic-danger | colors.danger | Negative status; losses |
--semiotic-warning | colors.warning | Cautionary states |
--semiotic-error | colors.error | Blocking errors |
--semiotic-info | colors.info | Informational callouts |
--semiotic-text | colors.text | Axis titles, legend labels |
--semiotic-text-secondary | colors.textSecondary | Tick labels, captions |
--semiotic-border | colors.border | Chart outlines, separators |
--semiotic-grid | colors.grid | Gridlines |
--semiotic-surface | colors.surface | Elevated fills (tooltip bg) |
--semiotic-bg | colors.background | Chart canvas background |
--semiotic-focus | colors.focus | Focus ring |
--semiotic-annotation-color | colors.annotation | Annotation markers/text |
How it works under the hood
Semiotic resolves theme colors through a layered pipeline. The layers are deliberate so that overrides compose predictably:
- Theme object is the source of truth —
SemioticTheme.colors holds every scalar, palette, scheme. - ThemeProvider emits CSS custom properties at its mount element for every scalar role.
- Scene builders receive concrete values via the rendering pipeline config (
pipelineConfig.themeSemantic), so canvas rendering has concrete hex without DOM reads in the hot path. - Canvas CSS-var reads (via
getComputedStyle) happen only for values passed explicitly as var(--...) strings in chart props — so the cascade override works for user-supplied values without costing per-paint lookups for theme defaults.
The upshot: a designer can set the theme once, override a single role with a CSS property, or swap a scale with a nested provider — each cleanly scoped, each without reaching for a per-chart override.