Skip to content
KONIGI

Dashboards / Color / Categorical series palette

1 of 6

Categorical series palette

Twelve services on one chart need twelve colors a person can tell apart.

Updated September 10, 2026

Problem

The chart has twelve lines. The legend has twelve swatches. Somewhere around the seventh, two of them are close enough that the viewer stops trusting the mapping and starts guessing.

Solution

A fixed ordered palette of qualitatively distinct hues, assigned deterministically, with a hard limit and a plan for what happens past it.

The number people can actually distinguish is smaller than any palette suggests. Somewhere between six and eight is the practical ceiling for hues that must be matched against a legend across a distance. Past that, colour stops identifying and starts merely decorating, and the honest responses are to group into an “other”, switch to small multiples, or use direct labelling instead of a key.

Three requirements decide whether a palette survives contact with real data.

Distinguishable for colour vision deficiency. Around one in twelve men and one in two hundred women have some form, most commonly red-green. A palette with red and green as adjacent series is unreadable for a meaningful slice of any audience, and this is the single most common failure. ColorBrewer’s qualitative schemes remain the best starting point precisely because they were built around this constraint rather than around looking pleasant.

Distinguishable when small and thin. A one-pixel line carries far less colour information than a legend swatch. Colours that separate cleanly as blocks converge as strokes, which is why palettes designed on filled shapes fail on line charts.

Stable assignment. If colour is assigned by query result order, a series that disappears shifts every colour after it, and the viewer’s learned mapping breaks silently. Assignment should hash from the series name or come from an explicit override, so checkout is the same colour today and next week and on the other dashboard.

WCAG’s rule applies here as everywhere: colour cannot be the only carrier. On a dense chart the practical answer is direct labelling at the line’s end, which removes the legend round-trip entirely.

Use when

Several series share one chart, the categories are unordered, and viewers need to track individual series across panels or over time.

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

Every colour spent on a series is unavailable for status, which is the collision that quietly ruins monitoring pages: a chart whose series happen to be red and green sitting beside tiles where red and green mean failure and health. Palettes look different on dark and light themes and usually need separate tuning. Brand palettes are rarely built for data and frequently offer three hues and a lot of tints. And the more series a chart carries, the more the palette is papering over a chart that should have been split.

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.

Dark-first data palette covers the theme problem this pattern inherits. Legend and series toggle is the mechanism that makes a large palette bearable. Semantic status color is the palette this one must not collide with. Small multiples is the answer once the series count outruns the colours. Sequential and diverging scales is the right choice when the categories turn out to have an order.

Categorical series palette anatomy Twelve numbered palette slots in a row. A marked ceiling falls after the eighth; the four beyond it are drawn empty. Below, the same six-series chart with a legend and with the lines labelled directly at their ends. Anatomy · an ordered palette with a limit 1 2 3 4 5 6 7 8 9 10 11 12 1 2 3 4 1 STABLE ASSIGNMENT Hash the slot from the series name. Assign by result order and one series dropping out shifts every colour after it, silently. 2 DISTINGUISHABLE TWICE Once for colour vision deficiency, once at one pixel wide. Hues that separate cleanly as blocks converge as strokes. 3 THE CEILING Six to eight is what a person can match back to a legend across a room. 4 PAST IT Group the tail into an "other", split into small multiples, or label the lines directly. What a legend costs Six series, two ways of saying which line is which. Legend · one round-trip per line checkout search cart user feed auth Direct labelling · no key at all
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.

npm
recharts
Tokens
--card--muted-foreground--border--chart-1--chart-2--chart-3--chart-4--chart-5--chart-6--chart-7--chart-8
09:0010:0011:0012:0013:0014:00checkoutsearchcartuserfeedauth

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.

Examples

Captures whose hotspots reference this pattern, grouped by product and dated. The dashed boxes are this pattern; hover any box for the note.

Cloudflare Radar

A public dashboard with no account, no filters worth the name, and an audience of journalists. Designed for people who will read one number and leave.

Worldwide Overview, dark September 11, 2026 Public site, signed out; no version string exposed medium · dark · desktop-web
The same page and the same figures, captured minutes apart on the other ground. Only the promotional strip at the top differs, and it rotates. Radar is worth the pair because it does not restate its palette in the dark: the hues are the same, and what changes is how much light each one carries against what is behind it. In the traffic chart the dark blue that reads as the heavier of the two series on white is now the one closest to the background, and the pale blue that was the quiet one carries the chart. The stacked attack bars show the milder version of the same thing—WAF still holds the largest share and still looks it, but the lighter segment beside it pulls more attention than its 39.7% has earned. The red on the browser deltas is the one colour that reads the same on both grounds, which is what you want from a colour carrying meaning and what a colour carrying identity cannot promise.
  • Dark-first data palette Same two hues as the light capture. On charcoal the darker series is the one fighting the ground.
  • Categorical series palette Four categories, four fixed hues, held across both grounds. Which one dominates the eye is not held.
  • Delta indicator The one colour that reads the same on both grounds, because it is carrying meaning rather than identity.
Show 3 more examples Hide the rest

Honeycomb

Query-first; heatmaps and BubbleUp replace the dashboard-of-panels model with draw-a-region cross-filtering.

Honeycomb — Trace / cart checkout
Trace waterfall. Indentation is causality, length is duration, horizontal position is when it started. Six levels deep here. Trace waterfall. The staircase: nineteen SELECTs one after another inside getDiscounts. Batch the query, don't add a machine. Detail on demand. Selecting a span fills the right pane with its fields. The waterfall never moves while you read. Overview then detail. A minimap of all 71 spans above the list, so the shape of the whole trace is visible before you scroll it. Categorical series palette. Five services, five hues, and the name in a column beside every one. Colour is never carrying it alone. Percentile summary. This span's duration against the whole distribution, with this trace marked—so you know if you're looking at the tail.
Trace / cart checkout September 11, 2026 Honeycomb sandbox, public dataset (signed out) dense · light · desktop-web
Seventy-one spans over 3.288 seconds for one checkout, and the shape gives the answer away before you read a single duration. Two thirds of the way down, getDiscounts runs for 2.576s—more than three quarters of the whole request —and underneath it nine visible SELECT spans step down and to the right in a staircase, each starting after the last one finished. The badge on the parent says 19. Nineteen queries in a loop, run one at a time, and the waterfall says so by its outline rather than by any number. That is the shape worth learning: siblings overlapping means concurrency, siblings in a staircase means something that should have been one query. The panel top right is the other good idea here—it plots the distribution of this span's duration across the whole dataset and marks where this particular trace fell, so you can see whether you are looking at a normal request or the tail before you start optimising.
  • Trace waterfall Indentation is causality, length is duration, horizontal position is when it started. Six levels deep here.
  • Trace waterfall The staircase: nineteen SELECTs one after another inside getDiscounts. Batch the query, don't add a machine.
  • Percentile summary This span's duration against the whole distribution, with this trace marked—so you know if you're looking at the tail.
  • Detail on demand Selecting a span fills the right pane with its fields. The waterfall never moves while you read.
  • Overview then detail A minimap of all 71 spans above the list, so the shape of the whole trace is visible before you scroll it.
  • Categorical series palette Five services, five hues, and the name in a column beside every one. Colour is never carrying it alone.

Tableau Public

Thousands of dashboards made by people who are not designers, published without a review step. The best available sample of what the pattern language looks like in the wild.

Energy flows in the Regional scenario September 11, 2026 Tableau Public embed view; workbook published by Chia Yu Lin medium · light · desktop-web
A Sankey doing the job it was invented for, published by an analyst rather than designed by Tableau. The entry argues that the form comes from engineering, where what enters a node leaves it, and that conservation is what makes band width readable as quantity—and that product analytics breaks it because people leave and nothing accounts for them. Here conservation holds and is stated: primary supply 546 TWh at the bottom left, final demand 476 TWh at the bottom right, and the missing 70 TWh has its own destination node called Conversion losses. Nothing disappears off the edge of the diagram. Colour is doing identity rather than status—blue for electricity, green for hydrogen, teal for biomass, orange for heat—and it stays consistent across all three columns, so a carrier can be traced from supply to end use without a legend. The one oddity is that the author has exposed the layout parameters as live controls, including a squish ratio printed to nine decimal places.
  • Sankey and path Three columns of nodes, link width as volume, and crossings kept to the few places where a carrier genuinely switches rank.
  • Sankey and path Conversion losses as an explicit destination. This is the node product analytics leaves out, and the reason its widths stop adding up.
  • Ratio and rate 546 TWh in, 476 TWh out, both stated. The diagram's own arithmetic is checkable from the page.
  • Categorical series palette Five carriers, five hues, held constant across every column so a band can be followed end to end.
  • Dashboard builder Curve type, whitespace and squish ratio exposed as reader-facing controls. The last one reads 0.484057971.

Grafana

The reference implementation for panel grids, template variables, and stat panels; most other tools are defined by how they differ from it.

Examples / Dashboard Variables September 10, 2026 Grafana Play (signed out; no version string exposed) medium · dark · desktop-web
Six selectors across the top and the page is unusually honest about what they cost. The middle panel says there is a hidden variable called bestfood, currently set to pizza, which has no control anywhere on screen—state affecting the page that a viewer cannot see or change. The overview panel says that setting Instance and then changing the Prometheus source will clear your selection, because the two variables are chained and the second one invalidates the first. Both of those are correct behaviour and both are the reason parameterised pages get distrusted. What the top row gets right is the display: Instance and CPU Usage Type show their selected values as chips with an × rather than as a count, so what is filtering the page is legible without opening anything.
  • Template variable Six selectors bound to every panel's query, with the chosen values carried in the URL.
  • Filter bar Values as removable chips rather than a dropdown saying Instance (3).
  • Explain this metric Names a hidden variable set to pizza. Prose is doing the work a control should.
  • Categorical series palette Twelve-plus hostnames differing only in the middle digits, with twelve-plus colours to match.
  • Data table Four columns visible, the fourth cut mid-word, with no horizontal scroll offered.