Skip to content
KONIGI Patterns

Dashboards / Labels and context / Legend and series toggle

‹ 5 of 6 ›

Legend and series toggle

People need to identify chart series and isolate the ones they want to compare.

Updated September 10, 2026

Problem

A crowded chart makes it difficult to compare the few series relevant to the current question.

Solution

Let people use the legend to isolate a series or select a comparison set. Make the behavior clear and provide a visible way to restore all series.

Show which series are currently visible. Support keyboard and touch use rather than requiring a modifier key or hover.

Keep names and useful summary values readable without letting the legend crowd out the chart. Use labels or line styles as well as colors to connect series with their names. Preserve the selection in shared views where supported.

Use when

People need to compare different subsets of the series shown in one chart.

Don’t use when

There is one series, where the legend is a label pretending to be furniture. Or when there are fifty, where the legend is a scrolling list nobody reads and small multiples would answer better.

Trade-offs

Hidden series can make a screenshot misleading. Large legends consume chart space, and unfamiliar toggle behavior can look like data disappeared. Many series may be better split into smaller charts.

Checklist

  • Does clicking a series isolate it or hide it, and is that discoverable?
  • Is there a reliable one-action restore?
  • Can the viewer build a set of two or three, and how?
  • Does the legend carry values or calculations, or only names?
  • Is placement right for the series count, and does it scroll rather than truncate?
  • Are series names readable, or truncated into ambiguity?
  • Does the chart’s meaning survive for someone who cannot distinguish the colours?
  • Does toggle state go into the URL or a shared link?
  • Is series order stable between refreshes?
  • At what series count should this be small multiples instead?

Compare

Grafana isolates on click and builds sets with Ctrl or Cmd, offers list and table legends with values in them, and lets the legend sit bottom or right, which makes it as much a summary table as a key. Datadog leans on hover-to-highlight over click-to-isolate, so exploring is transient and nothing persists into a state someone could misread. Honeycomb mostly avoids the problem by grouping at query time: rather than toggling fourteen series off a chart, you change what you grouped by and get the two you wanted. Netdata puts a dimension list under every chart with per-dimension toggling, which at hundreds of charts makes the legend the primary way of narrowing anything.

Time series supplies the chart. Categorical series palette distinguishes the lines. Hover detail identifies values at a point.

Small multiples separates crowded series. Cross-filter extends selection to other panels.

Legend and series toggle anatomy Fourteen series with a table-mode legend on the right carrying a value column. Below, the same chart after one click on a legend label, which isolates that series rather than hiding it. Fourteen series, legend as a control Series Last checkout 312 search 208 cart 184 user 141 feed 96 9 more 1 2 1 PLACEMENT On the right it costs width permanently and scrolls gracefully. At the bottom it pushes the chart up and gets cut off. 2 TABLE MODE A value column turns the legend into a sortable summary, and often removes the need for a panel next to it. One click on a label It isolates rather than hides, because someone with fourteen series wants one. checkout 312 13 hidden
Wireframe — the pattern's anatomy, not any one product's version of it

Implementation

The legend does something here. Once a chart has more than three series the reader needs to isolate one, and making that work inside the chart is cheaper than a filter that reloads the page.

npm
recharts
Tokens
--card--card-foreground--muted-foreground--border--chart-1--chart-2--chart-3--chart-4--chart-5--chart-6--chart-7--chart-8
SeriesLast
  • 312
  • 208
  • 184
  • 141
  • 96

LegendChart.tsxA table-mode legend that isolates on click, adds on modifier click, restores on the second click, and folds the tail behind a count.

import { useState, type MouseEvent } from "react";
import { Line, LineChart, XAxis, YAxis } from "recharts";
import { seriesColors } from "../categorical-series-palette/seriesColor";

export type Series = { key: string; values: number[] };

/**
 * The legend is the control. One click on a label isolates that series,
 * because someone looking at fourteen almost always wants one; a modifier
 * click adds to the set; clicking the last selected label restores all.
 * Table mode carries the last value, which is usually the number the viewer
 * came for and saves a panel beside the chart.
 */
export function LegendChart({ labels, series, rows = 5, defaultSelected = [], onSelectionChange, width = 420, height = 170 }: {
  labels: string[];
  series: Series[];
  /** Legend rows shown before "n more". The rest are one click away, not gone. */
  rows?: number;
  /** Isolated keys. Empty means every series is drawn. Put this in the URL. */
  defaultSelected?: string[];
  onSelectionChange?: (keys: string[]) => void;
  width?: number;
  height?: number;
}) {
  const [selected, setSelected] = useState<string[]>(defaultSelected);
  const [expanded, setExpanded] = useState(false);

  const select = (keys: string[]) => { setSelected(keys); onSelectionChange?.(keys); };
  const toggle = (key: string, e: MouseEvent) => {
    const additive = e.metaKey || e.ctrlKey;
    if (additive) select(selected.includes(key) ? selected.filter((k) => k !== key) : [...selected, key]);
    else if (selected.length === 1 && selected[0] === key) select([]);
    else select([key]);
  };

  // Sorted by last value, so the legend is the summary table it looks like.
  const sorted = [...series].sort((a, b) => b.values.at(-1)! - a.values.at(-1)!);
  const { assigned, other } = seriesColors(sorted.map((s) => s.key));
  const colorOf = (key: string) => assigned.find((a) => a.key === key)?.color ?? other!.color;
  const isolated = selected.length > 0;
  const shown = isolated ? sorted.filter((s) => selected.includes(s.key)) : expanded ? sorted : sorted.slice(0, rows);
  const hidden = isolated ? series.length - selected.length : series.length - shown.length;

  const data = labels.map((label, i) => Object.fromEntries([["label", label], ...series.map((s) => [s.key, s.values[i]])]));

  return (
    <div className="grid grid-cols-[1.3fr_1fr] gap-5">
      <div className="overflow-x-auto rounded-lg border bg-card p-4">
        <LineChart width={width} height={height} data={data} margin={{ top: 4, right: 4, bottom: 0, left: 0 }}>
          <XAxis interval={0} dataKey="label" hide />
          <YAxis hide domain={[0, "dataMax + 20"]} />
          {sorted.map((s) => (
            <Line key={s.key} dataKey={s.key} stroke={colorOf(s.key)} strokeWidth={isolated ? 2.5 : 2}
              hide={isolated && !selected.includes(s.key)} dot={false} isAnimationActive={false} />
          ))}
        </LineChart>
      </div>

      <div className="rounded-lg border bg-card p-4">
        <div className="flex items-baseline justify-between border-b pb-2 text-[11px] uppercase tracking-wide text-muted-foreground">
          <span>Series</span><span>Last</span>
        </div>
        <ul className="mt-1 text-xs tabular-nums">
          {shown.map((s) => (
            <li key={s.key} className="flex items-center gap-2 py-1.5">
              <span className="size-2.5 rounded-[2px]" style={{ background: colorOf(s.key) }} />
              <button type="button" onClick={(e) => toggle(s.key, e)} className="text-card-foreground hover:underline"
                aria-pressed={selected.includes(s.key)} title="Click to isolate. Cmd-click to add. Click again to restore.">
                {s.key}
              </button>
              <span className="ml-auto text-card-foreground">{s.values.at(-1)}</span>
            </li>
          ))}
        </ul>
        {hidden > 0 && (
          <button type="button" className="mt-1 w-full border-t pt-2 text-left text-[11px] text-muted-foreground hover:underline"
            onClick={() => (isolated ? select([]) : setExpanded(true))}>
            {isolated ? `${hidden} hidden` : `${hidden} more`}
          </button>
        )}
      </div>
    </div>
  );
}

demo.tsxHow it is called: fourteen services by requests per second, five legend rows, the rest one click away.

import { LegendChart } from "./LegendChart";

/**
 * Fourteen services, requests per second over seven hours. The legend shows
 * the top five and folds the rest; click "checkout" to isolate it and the
 * footer reads "13 hidden". The eight busiest get a colour by key, the rest
 * share the muted "other".
 */
const LABELS = ["09:00", "10:00", "11:00", "12:00", "13:00", "14:00", "15:00"];

const SERIES = [
  { key: "checkout", values: [220, 236, 226, 268, 258, 292, 312] },
  { key: "search", values: [150, 158, 154, 176, 168, 194, 208] },
  { key: "cart", values: [128, 134, 130, 152, 148, 170, 184] },
  { key: "user", values: [96, 100, 98, 114, 110, 130, 141] },
  { key: "feed", values: [68, 70, 66, 80, 78, 90, 96] },
  { key: "auth", values: [60, 58, 64, 70, 72, 80, 84] },
  { key: "payments", values: [48, 52, 50, 58, 60, 66, 71] },
  { key: "inventory", values: [42, 44, 46, 50, 54, 58, 63] },
  { key: "shipping", values: [36, 38, 36, 42, 44, 48, 52] },
  { key: "notifications", values: [30, 32, 30, 36, 38, 40, 44] },
  { key: "catalog", values: [26, 28, 26, 30, 32, 34, 37] },
  { key: "reviews", values: [20, 22, 20, 24, 26, 28, 29] },
  { key: "recommendations", values: [16, 16, 18, 18, 20, 22, 22] },
  { key: "media", values: [12, 12, 14, 12, 14, 14, 15] },
];

export default function Demo() {
  return <LegendChart labels={LABELS} series={SERIES} onSelectionChange={(keys) => console.log("isolated:", keys)} />;
}
What it renders. Identical markup in both 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.

Grafana

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

Examples / Geomap September 10, 2026 Grafana Play (signed out; no version string exposed) medium · dark · desktop-web
The same flight data appears on several map backgrounds. Small green markers are difficult to distinguish on the dark maps and compete with terrain colors on the satellite view. The density layer makes broad clusters easier to see, though its settings affect how widely those clusters spread. Legends labeled Layer 1 do not explain the data being shown.
Show 2 more examples Hide the rest

Grafana

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

Examples / Node graph panel September 10, 2026 Grafana Play (signed out; no version string exposed) sparse · dark · desktop-web
Several service nodes extend beyond the panel’s right edge, so the full graph is not visible at once. Each node contains two values and a colored ring, with a legend explaining the measures. This adds detail but makes the map harder to scan for a single overall state. The zoom control helps reach the clipped nodes, though a clearer initial fit would make the relationships easier to inspect.

Netdata

A large collection of system charts with per-chart controls and anomaly-rate views.

Metrics / System › Compute › CPU September 10, 2026 Netdata Agent (public registry node, signed out) dense · dark · desktop-web
This section follows several nested levels of the generated metric hierarchy. Leading dashes indicate depth, while a breadcrumb identifies the current CPU section. Each chart includes its own grouping and aggregation controls. The Anomaly Rate option is visible but switched off, so this capture demonstrates the control rather than the resulting anomaly display.