Skip to content
KONIGI Patterns

Dashboards / Color / Categorical series palette

1 of 6 ›

Categorical series palette

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.

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.

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

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.

Cloudflare Radar

Public internet-traffic dashboards with location and time controls and links from summaries to detailed views.

Worldwide Overview, dark September 11, 2026 Public site, signed out; no version string exposed medium · 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.
Show 3 more examples Hide the rest

Honeycomb

Query-driven views use heatmaps and selected regions to investigate differences between requests.

Trace / cart checkout September 11, 2026 Honeycomb 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.

Tableau Public

Published workbooks show how individual authors arrange charts and interactions in Tableau.

Energy flows in the Regional scenario September 11, 2026 Tableau Public embed view; workbook published by Chia Yu Lin medium · 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.

Grafana

Examples of panel grids, shared selectors, stat tiles, and chart configuration.

Examples / Dashboard Variables September 10, 2026 Grafana 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.