People need to tell chart series apart and recognize them across views.
Updated September 10, 2026
Problem
When chart colors are too similar, people cannot reliably match a line to its legend.
Solution
Use distinct colors for categories and keep each category’s assignment stable across charts and updates. Test the colors on thin lines as well as large swatches, in every supported theme.
Check that people with color-vision differences can distinguish the series. Add direct labels, line styles, or other cues so color is not the only identifier.
Limit the number of series shown together. When colors become hard to tell apart, group less important categories, allow filtering, or split the chart into small multiples.
Use when
Several unordered categories share a chart and need consistent identities across views.
Don’t use when
The categories have an order or a magnitude, where a sequential ramp encodes more. Or when the count is beyond what anyone can distinguish, which is the case more often than palettes admit.
Trade-offs
Category colors can be confused with colors used for warnings or health. More series make matching harder even with a good palette. Colors that work on one background may need adjustment on another.
Checklist
How many series can appear here at most, and does the palette cover that?
Do any two adjacent-in-value series share similar hues?
Does the palette survive the common colour vision deficiencies?
Do the colours separate as thin lines, not just as legend swatches?
Is colour assigned deterministically from the series name, or by result order?
Does the same category get the same colour on every panel and dashboard?
Do any series colours collide with the status palette?
Is there a defined behaviour past the palette’s limit?
Would direct labelling remove the need for a legend entirely?
Does the palette have a tested dark-theme variant?
Compare
ColorBrewer is still the reference, because Cynthia Brewer’s schemes were designed against colourblind-safe, print-safe and photocopy-safe criteria rather than aesthetics, and its qualitative sets remain the best default anyone can adopt in an afternoon. Grafana ships a classic palette assigned by series order in many panels, with per-series overrides available, which means dashboards drift in colour meaning as queries change unless someone pins them. Datadog hashes series colours from tag values so the same tag keeps its colour across widgets, which is the behaviour worth copying. Observable Plot and D3 default to well-researched schemes and make the limit explicit by forcing a decision when the category count exceeds the scheme.
Related
Dark-first data palette handles dark backgrounds. Legend and series toggle lets people isolate series. Semantic status color needs a separate, clear meaning.
Small multiples reduces dependence on color matching. Sequential and diverging scales represents ordered values.
Wireframe — the pattern's anatomy, not any one product's version of it
Implementation
shadcn ships --chart-1 through --chart-5 and no opinion about what happens at six, or about assigning them. Index a palette by array position and the same series changes colour when a filter changes the result order.
seriesColor.tsDeterministic from the series name, so a colour travels with its series across panels. Past the limit, one muted other.
/** * Colour is a property of the series, not of its position in an array. * * Indexing by result order is the common shortcut and it means "checkout" is * blue on one panel and orange on the next, or changes colour when a filter * removes a row above it. Hashing the key fixes it for free: the same name gets * the same colour on every panel, every dashboard, every session. */const PALETTE_SIZE = 8;/** FNV-1a. Small, stable across runs, and not trying to be a hash function. */function hash(key: string): number { let h = 0x811c9dc5; for (let i = 0; i < key.length; i++) { h ^= key.charCodeAt(i); h = Math.imul(h, 0x01000193); } return h >>> 0;}const slot = (key: string) => hash(key) % PALETTE_SIZE;/** The tokens are HSL triplets, so the reference has to be wrapped. */const chart = (n: number) => `hsl(var(--chart-${n + 1}))`;/** One series on its own, with no others to collide with. */export function seriesColor(key: string): string { return chart(slot(key));}/** * A set of series that share a chart. Two keys can hash to one slot, and two * lines in one colour is the failure this pattern exists to prevent, so a * collision moves the later key (in sorted order, never result order) to the * next free slot. That bumped key can move again if the one it collided with * disappears; a series that must never move gets pinned in `overrides`. * * Past the palette limit, stop colouring. "Is there a defined behaviour past * the palette's limit?" is a checklist item, and recycling hues is the wrong * answer: everything beyond the top N is folded into one muted "other". */export function seriesColors(keys: string[], max = PALETTE_SIZE, overrides: Record<string, number> = {}) { const top = keys.slice(0, max); const rest = keys.slice(max); const taken = new Set(Object.values(overrides)); const slots = new Map<string, number>(Object.entries(overrides)); for (const key of [...top].sort()) { if (slots.has(key)) continue; let s = slot(key); while (taken.has(s)) s = (s + 1) % PALETTE_SIZE; taken.add(s); slots.set(key, s); } return { assigned: top.map((key) => ({ key, color: chart(slots.get(key)!) })), other: rest.length ? { key: `${rest.length} more`, color: "hsl(var(--muted-foreground))" } : null, };}
SeriesLines.tsxA line per series, coloured by key and labelled at the line's end so there is no legend to round-trip to.
import { Line, LineChart, XAxis, YAxis, LabelList } from "recharts";import { seriesColors } from "./seriesColor";/** * Several series on one chart, coloured by name and labelled at the line's * end. The colour comes from the key, so `checkout` is the same hue on this * panel, the next one, and next week. The label sits where the line stops, * which removes the legend round-trip and means colour is never the only * thing telling two lines apart. * * Past `max` series the rest are summed into one muted "other". Recycling * hues would give two series one colour, and that is worse than one series * having none. */export type Series = { key: string; values: number[] };type Props = { series: Series[]; /** One per point, in order. Shown on the x axis. */ labels: string[]; /** Six to eight is what a person can match back across a room. */ max?: number; width?: number; height?: number;};/** The label at the line's end. Rendered once, at the last point. */const EndLabel = ({ x, y, value, index, last, fill }: { x?: number; y?: number; value?: unknown; index?: number; last: number; fill: string }) => index === last && typeof x === "number" && typeof y === "number" ? ( <text x={x + 6} y={y} dy={3} fontSize={10} fill={fill}>{String(value)}</text> ) : null;export function SeriesLines({ series, labels, max = 8, width = 400, height = 160 }: Props) { const { assigned, other } = seriesColors(series.map((s) => s.key), max); const byKey = new Map(series.map((s) => [s.key, s.values])); const lines = assigned.map(({ key, color }) => ({ key, color, values: byKey.get(key)! })); if (other) { const tail = series.slice(max); lines.push({ ...other, values: labels.map((_, i) => tail.reduce((n, s) => n + s.values[i], 0)) }); } const rows = labels.map((label, i) => Object.fromEntries([["label", label], ...lines.map((l) => [l.key, l.values[i]])]), ); const last = labels.length - 1; return ( <div className="overflow-x-auto"> <LineChart width={width} height={height} data={rows} margin={{ top: 8, right: 64, bottom: 0, left: 0 }}> <XAxis interval={0} dataKey="label" tickLine={false} axisLine={false} fontSize={10} stroke="hsl(var(--muted-foreground))" /> <YAxis hide domain={[0, "dataMax"]} /> {lines.map((l) => ( <Line key={l.key} dataKey={l.key} stroke={l.color} strokeWidth={1.5} dot={false} isAnimationActive={false} > {/* The series name, not the value: the label replaces the legend. */} <LabelList dataKey={() => l.key} content={<EndLabel last={last} fill={l.color} />} /> </Line> ))} </LineChart> </div> );}
demo.tsxHow it is called: six routes over six hours, under the ceiling of eight.
import { SeriesLines } from "./SeriesLines";/** * Requests per second by route over six hours. Six series, under the ceiling, * each labelled where its line ends. Add a ninth and it folds into "1 more". */export default function Demo() { return ( <div className="rounded-lg border bg-card p-4"> <SeriesLines labels={["09:00", "10:00", "11:00", "12:00", "13:00", "14:00"]} series={[ { key: "checkout", values: [62, 66, 72, 74, 78, 82] }, { key: "search", values: [54, 58, 60, 66, 66, 70] }, { key: "cart", values: [48, 46, 52, 54, 56, 58] }, { key: "user", values: [38, 42, 40, 44, 42, 46] }, { key: "feed", values: [28, 26, 30, 32, 30, 34] }, { key: "auth", values: [16, 18, 16, 20, 20, 22] }, ]} /> </div> );}
What it renders. Identical markup in all three panes, with only the token values changing.
Screenshots are grouped by product and include the capture date.
Dashed boxes highlight this pattern. Notes appear below each screenshot and when you hover over a box.
Worldwide Overview, dark September 11, 2026Public site, signed out; no version string exposedmedium · dark · desktop-web
This capture shows Radar’s dark theme alongside the light version captured minutes earlier. The traffic series use the same hues, but the darker blue is harder to distinguish from the dark background. The lighter segments in the stacked bars also stand out more. The red browser-change labels remain prominent in both themes. The pair shows why a palette needs testing on each background.
Trace / cart checkout September 11, 2026Honeycomb sandbox, public dataset (signed out)dense · light · desktop-web
The trace contains seventy-one spans across one checkout request. The getDiscounts operation occupies much of the total duration, with visible SELECT spans arranged sequentially beneath it. That sequence is a useful place to investigate repeated work, though the chart alone does not establish the right fix. A distribution panel places the selected span’s duration among other observations, helping people judge whether this request is typical.
Trace waterfallIndented bars show parent-child relationships and timing.
Trace waterfallSequential SELECT operations within getDiscounts are worth investigating.
Percentile summaryThe distribution marks this trace among other durations.
Detail on demandSelecting a span opens its fields beside the waterfall.
Energy flows in the Regional scenario September 11, 2026Tableau Public embed view; workbook published by Chia Yu Linmedium · light · desktop-web
The diagram follows energy from supply to demand using band widths for quantity. It includes conversion losses as a separate destination, so the difference between the stated supply and demand has an explanation. Carrier colors stay consistent across the columns, helping readers follow a flow. Layout controls are exposed beside the chart, including a highly precise spacing value that adds little to the data reading.
Sankey and pathBand widths show quantity across three sets of nodes.
Sankey and pathConversion losses have an explicit destination.
Ratio and rateSupply and demand totals let readers check the overall quantities.
Examples / Dashboard Variables September 10, 2026Grafana Play (signed out; no version string exposed)medium · dark · desktop-web
This dashboard demonstrates six selectors and explains how their values affect the page. Selected values appear as removable chips, making the active scope easy to read. A text panel also describes a hidden variable that has no visible control. Another note explains that changing the data source clears an instance selection. These dependencies need clear feedback when people change a value.
Template variableSelectors provide values used by panel queries and shared in the URL.
Filter barSelected values appear as removable chips.
Explain this metricA text panel explains a hidden variable and its current value.