Five of the six chart components paint through one colour contract: NbBarChart, NbLineChart, NbPieChart, NbGanttChart and NbInterpolationChart all resolve their marks through the same module. (NbSparkline is the exception and takes a single color, defaulting to var(--nb-c-primary).) This page is that contract: which of the three scales a question needs, in what order colours are assigned, how many series colour can carry before it stops working, and what a white-label product overrides.
Charts never name a Nubisco ramp. They paint through eight role tokens, --nb-c-chart-1 to --nb-c-chart-8, plus five ramp anchors. In the light theme the defaults resolve to exactly the ramps the charts painted before the roles existed, so nothing moves. In the dark theme every chart repaints once, see Upgrading below.
Pick the scale first
| Your data | Scale | What carries meaning | In this library |
|---|---|---|---|
| Unordered groups: regions, plans, tenants, error classes | Categorical | Hue | --nb-c-chart-1 … -8, colorAt(), seriesColors() |
| One-directional magnitude: volume, density, count, duration | Sequential | Lightness within one hue | sequentialAt(t) |
| Signed distance from a meaningful zero: variance to target, delta | Diverging | Hue on each side, lightness | divergingAt(t) |
Two failure modes are worth naming, because both have shipped:
- A categorical palette on ordered data invents groups. Months rendered in eight hues read as eight unrelated things.
- A sequential ramp on unordered data invents a ranking. Five tenants shaded light to dark say one tenant is more than another, and the reader will believe it.
Diverging has a third: an asymmetric domain. If the ramp runs from -5 to +40 without pinning the neutral band to zero, the colour that means "on target" lands at +17.5 and every chart on the page lies in the same direction.
Categorical roles
With no colors prop, roles are assigned in role order and wrap at eight. Two consequences follow, and both are load-bearing:
- Series index decides colour. Reordering a
seriesarray recolours the chart. If a dashboard shows the same three tenants in four charts, pass them in the same order in all four, or pin the colour per series with thecolorfield onIChartSeries/ICategoricalDatum. - Role 9 is role 1 again.
colorAt()wraps, so a chart handed twelve series renders twelve marks in eight colours. Nothing warns you. That is a legibility failure, not a feature.
<template>
<NbLineChart :series="series" />
</template>
<script setup lang="ts">
// Same tenant, same colour, in every chart on the page.
const series = [
{ name: 'Acme', color: 'var(--nb-c-chart-1)', data: acme },
{ name: 'Globex', color: 'var(--nb-c-chart-2)', data: globex },
]
</script>Role order is frozen: role N is the colour charts already paint, and reordering the array would recolour every chart in every app that has one. It is not the order that reads best. It opens with three violet-blues (roles 1, 6 and 7), so a chart that uses roles 1 to 5 gets a worse worst-pair than the same palette can give.
When you know the series count
seriesColors(count) picks the best-separated subset of the same eight roles for exactly that many series, instead of taking the first count in role order:
<template>
<NbBarChart :series="regions" :colors="seriesColors(regions.length)" />
<!-- Strokes and points need contrast against the surface too. -->
<NbLineChart
:series="regions"
:colors="seriesColors(regions.length, { mark: 'line' })"
/>
</template>
<script setup lang="ts">
import { seriesColors } from '@nubisco/ui'
</script>The sets are nested, so growing the chart appends a colour rather than reshuffling the ones already drawn: seriesColors(4) is seriesColors(3) plus one. Concretely the fill order is roles 2, 4, 5, 8, 3, 6, 1, 7 and the line order is 2, 5, 8, 6, 1, 7, 4, 3.
Minimum CIEDE2000 between any two colours in the returned set, light theme, measured off the compiled stylesheet:
| Series | seriesColors(n) normal / deuter. / protan. / tritan. | Role order (default) |
|---|---|---|
| 2 | 62.3 / 36.7 / 74.4 / 53.5 | 16.0 / 5.2 / 9.5 / 13.2 |
| 3 | 36.2 / 17.1 / 27.2 / 24.7 | 16.0 / 5.2 / 9.5 / 12.8 |
| 4 | 17.9 / 17.1 / 21.2 / 16.7 | 16.0 / 5.2 / 9.5 / 12.8 |
| 5 | 17.9 / 9.4 / 14.1 / 12.8 | 16.0 / 5.2 / 9.5 / 12.8 |
| 6 | 10.0 / 8.8 / 7.6 / 8.8 | 10.0 / 5.2 / 7.6 / 5.8 |
| 7 | 10.0 / 5.2 / 7.6 / 5.8 | 6.5 / 0.8 / 2.1 / 5.8 |
| 8 | 6.5 / 0.8 / 2.1 / 5.8 | 6.5 / 0.8 / 2.1 / 5.8 |
At eight the two agree, because there is only one set of eight. Everywhere below that the curated set is better or equal, and at seven it is the difference between a deuteranope seeing seven colours and seeing six (0.8 is the same colour twice).
{ mark: 'line' } is a second, tighter search. A 2px line stroke has to clear 3:1 against the surface as well as separate from its neighbours, and roles 3 and 4 do not manage that in the light theme (2.69 and 1.75, see the Accessibility tab), so the line order draws from the six roles that do and appends the other two only if you ask for seven or eight:
| Series | Line set (roles) | normal / deuter. / protan. / tritan. |
|---|---|---|
| 2 | 2, 5 | 45.5 / 37.7 / 60.0 / 54.6 |
| 3 | 2, 5, 8 | 17.9 / 18.6 / 21.2 / 16.7 |
| 4 | 2, 5, 8, 6 | 10.0 / 8.8 / 7.6 / 8.8 |
| 5 | 2, 5, 8, 6, 1 | 10.0 / 5.2 / 7.6 / 5.8 |
| 6 | 2, 5, 8, 6, 1, 7 | 6.5 / 0.8 / 2.1 / 5.8 |
Two caveats. seriesColors() is opt-in and is a recolour: a chart that switches to it changes which series is which colour once, so switch a whole dashboard at a time, not one chart. And if you pin a series colour so it stays stable across pages, keep pinning it; a curated set is per chart, not per tenant.
How many series before colour stops working
Colour is the weakest channel a chart has. Reading the tables above as a rule:
- Up to 4 series: comfortable in either ordering, and still needs the redundant cues on the Accessibility tab.
- 5 series: fine with
seriesColors(5)(worst pair 9.4 under deuteranopia). In role order the worst pair is 5.2, which is noticeable but tiring; add direct labels. - 6 series: acceptable with direct labels on the marks.
- 7 or more: colour is no longer encoding anything. Facet into small multiples, aggregate the tail into an "Other" series, or switch to a chart type that separates by position (grouped bars, a sorted bar chart) rather than by hue.
Those caps are a property of the default ramps, which contain three violet-blues, not of the role indirection. A product that overrides the roles with eight well-separated hues gets a better palette and the caps go up with it, seriesColors() included, because the search is over whatever the tokens resolve to.
Do not borrow the status colours
Role 3 resolves to the same green as --nb-c-success and role 5 to the same red as --nb-c-danger. In a chart of four regions that is coincidence, and readers mostly cope. In a chart where anything is passing or failing, it is a trap: the third region is not "good".
When a chart encodes status, name the status tokens and skip the roles entirely:
<template>
<NbBarChart
:series="series"
:colors="['var(--nb-c-status-valid)', 'var(--nb-c-status-error)']"
/>
</template>--nb-c-status-valid, --nb-c-status-warning and --nb-c-status-error are the foreground status tokens, held to 3:1 on every surface a card paints. The --nb-c-success / -danger family are button fills and are a step darker.
NbGanttChart is the one chart with a status vocabulary built in. Its five statuses default to roles (1, 3, 4, 5 and 2, in the order default, on-track, at-risk, behind, complete) and are replaced wholesale with statusColors, which is where the status inks belong if a product wants them:
<NbGanttChart
:tasks="tasks"
:status-colors="{
'on-track': 'var(--nb-c-status-valid)',
'at-risk': 'var(--nb-c-status-warning)',
behind: 'var(--nb-c-status-error)',
}"
/>Sequential
sequentialAt(t) returns a point on the single-hue ramp for t in 0 to 1, as a color-mix() in oklab between --nb-c-chart-sequential-from and --nb-c-chart-sequential-to. The mixing happens in CSS, at paint time, so the ramp follows the theme and any override without a re-render, and oklab keeps the steps perceptually even (an sRGB midpoint of a saturated pair reads as a dark band the data does not contain).
<template>
<NbBarChart :series="[{ name: 'Requests', data }]" :colors="shades" />
</template>
<script setup lang="ts">
import { sequentialAt, rampSteps } from '@nubisco/ui'
const max = Math.max(...data.map((d) => d.y))
const shades = data.map((d) => sequentialAt(d.y / max))
// Or five bins for a legend the reader can match a swatch against:
const legend = rampSteps(5)
</script>In the light theme the largest value is the darkest end; under .dark the ramp inverts and the largest value is the brightest. Both directions come from the tokens, so the same t is correct in both themes.
Bin before you shade. Five to seven steps is the most a reader can match back to a legend; a continuous ramp is for dense fields (thousands of heat cells) where individual lookup was never the point. And give the low end help: it sits deliberately close to the page ground, so a lone low-value cell has almost no contrast against the surface. Add stroke: var(--nb-c-border) and a value label, or a legend with numeric bounds.
Diverging
divergingAt(t) runs --nb-c-chart-diverging-low at t = 0 through -mid at 0.5 to -high at 1. Map the domain symmetrically around the real midpoint, or the neutral band is meaningless:
import { divergingAt } from '@nubisco/ui'
// Variance to target, in percent, clamped to a symmetric domain.
const extent = Math.max(...values.map((v) => Math.abs(v)))
const shade = (v: number) => divergingAt(0.5 + v / (2 * extent))The default poles are cool to warm, not green to red. Green versus red is the one axis a deuteranope cannot read at all, and a diverging scale has no legend row to fall back on: the reader has to tell the sign of a value from its hue alone.
White-labelling
Override the roles once, on :root, in the product's own stylesheet. Every chart in the app follows, including ones the product has not written yet.
:root {
--nb-c-chart-1: #0b6b3a;
--nb-c-chart-2: #1f7ae0;
--nb-c-chart-3: #b8860b;
--nb-c-chart-4: #8a3ffc;
--nb-c-chart-5: #d02670;
--nb-c-chart-6: #007d79;
--nb-c-chart-7: #6f6f6f;
--nb-c-chart-8: #a2191f;
--nb-c-chart-sequential-from: #e3f2ea;
--nb-c-chart-sequential-to: #084023;
--nb-c-chart-diverging-low: #1f7ae0;
--nb-c-chart-diverging-mid: #d5d7da;
--nb-c-chart-diverging-high: #a2191f;
}
// The dark theme is a separate ramp, not the same colours on a dark ground.
// The eight light values above are chosen against a white page and several of
// them will fall below 3:1 on a dark one.
.dark {
--nb-c-chart-1: #42be65;
// ...
--nb-c-chart-sequential-from: #0d2a1c;
--nb-c-chart-sequential-to: #a7f0ba;
}Because var() resolves on the element that paints, the same override works scoped to one dashboard or one widget:
.tenant-branded-report {
--nb-c-chart-1: var(--tenant-primary);
--nb-c-chart-2: var(--tenant-secondary);
}Two things to check after an override, both covered on the Accessibility tab: every role clears 3:1 against the surface the chart sits on, in both themes, and no two of the roles you actually use collapse under a colour-vision simulation. seriesColors() picks from whatever you set, so it stays useful, but its ordering was searched against the defaults; re-run the search if a rebrand changes which pairs are close.
Charts are not the only place the brand used to leak. The ghost button's hover border named the same violet ramp and now mixes --nb-c-primary, so setting the brand is enough there too. --nb-c-primary and these thirteen chart tokens are the whole colour surface a white-label product has to think about.
The per-chart colors prop still exists and still wins. It is the right tool for one chart that means something specific (a status chart, a ramp, a curated set). It is the wrong tool for branding, which is what the tokens are for: thirteen declarations in one file instead of a colors array at every call site.
Upgrading
Two visible changes land with the roles, and one non-change worth knowing about.
- Light theme: nothing moves. Each role resolves to exactly the ramp its chart painted before, asserted per role in
tests/chartRoles.test.ts.NbGanttChart's five status defaults are included in that: they are roles now, and the roles resolve to the ramps they replaced. - Dark theme: every chart repaints, once. The old palette had no theme branch, so a dark chart painted the light colours on a dark ground. Six of those eight miss WCAG 1.4.11 somewhere on layer-0 to layer-2 there, roles 1 and 7 worst at 1.79 and 1.85; only roles 3 and 4 survive the move. All eight dark roles are now a lighter step of the same hue and all eight clear 3:1 on those surfaces. Screenshot tests over dark charts will need new baselines. Nothing about the API changes, so no code has to move.
NbInterpolationChartgains a workingcolorsprop. It declared one and never read it, painting a hardcoded violet. It now takes the first entry ofcolors, or role 1. Light theme unchanged; dark theme repaints with the rest.