Legend

What the colours mean (a key), and a switch for every series: a click hides it and shows it again, a double click shows only it (a solo).

import { Legend, useChart } from "@umriss-ui/charts";

The legend toggles by default: every entry of a named series is a button that says whether its series is shown. The hidden series are part of the chart's view, by name - no state of yours, and kept like the rest of the view, on View. A series without a name cannot be hidden; its entry only explains.

A double click, Alt/⌥+click or Shift+Enter on an entry shows only its series; on the only one visible, all again. A click acts at once rather than wait for a second, so a double click first toggles the series and back - its result is the same either way.

A hidden series leaves the drawing, the y extent and the keys' walk; its entry stays, struck through, and keeps its colour, so showing it again moves nothing else. Nothing hides every series: what would shows all instead. A state band's entries are its states, and any of them hides or shows only the whole band.

On the hook, hidden, toggleSeries(name), showOnly(name) and showAllSeries() do the same from your own controls.

Examples

Hide a series from the legend

A click on an entry hides its series and shows it again, through the chart's view - no state of yours. A double click, Alt/⌥+click or Shift+Enter shows only it, and on the only one visible all again; showOnly and showAllSeries on the hook do the same from your own buttons.

import { Legend, Tooltip, useChart } from "@umriss-ui/charts";

/* Data from the operations world, written out here so the example runs on its own. */
/** A small LCG - the same numbers on every computer. */
function random(seed: number): () => number {
  let s = seed >>> 0;
  return () => {
    s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
    return s / 4294967296;
  };
}

const at = (day: number, hours: number, minutes = 0) => new Date(2026, 2, day, hours, minutes).getTime();
const MINUTE = 60_000;

/** Tuesday, 17 March 2026, 10:30 - the moment the screens are read at. */
const NOW = at(17, 10, 30);

interface Service {
  id: string;
  name: string;
  team: string;
  /** 1 is customer-facing and pages at night; 3 waits for the morning. */
  tier: 1 | 2 | 3;
  /** The latency objective: the 95th percentile stays below this, in ms. */
  latencySlo: number;
  /** The availability promised for a month, in per cent. */
  availabilityTarget: number;
}

const SERVICES: readonly Service[] = [
  { id: "checkout", name: "Checkout", team: "Payments", tier: 1, latencySlo: 300, availabilityTarget: 99.95 },
  { id: "billing", name: "Billing", team: "Payments", tier: 1, latencySlo: 400, availabilityTarget: 99.9 },
  { id: "sign-in", name: "Sign-in", team: "Identity", tier: 1, latencySlo: 200, availabilityTarget: 99.95 },
  { id: "search", name: "Search", team: "Discovery", tier: 1, latencySlo: 250, availabilityTarget: 99.9 },
  { id: "images", name: "Image service", team: "Discovery", tier: 2, latencySlo: 500, availabilityTarget: 99.5 },
  { id: "notifications", name: "Notifications", team: "Messaging", tier: 2, latencySlo: 800, availabilityTarget: 99.5 },
  { id: "webhooks", name: "Webhooks", team: "Integrations", tier: 2, latencySlo: 1000, availabilityTarget: 99.5 },
  { id: "reports", name: "Reporting", team: "Insights", tier: 3, latencySlo: 2000, availabilityTarget: 99 },
];

interface MetricPoint {
  t: number;
  /** Median and 95th percentile latency, in ms. */
  p50: number;
  p95: number;
  /** Failed requests, in per cent. */
  errorRate: number;
  /** Requests per minute. */
  requests: number;
}

/** Requests per minute at the busiest hour, by tier. */
const VOLUME = { 1: 900, 2: 400, 3: 120 } as const;

/** A service's latency and error rate every five minutes of today, from
    midnight up to now. Busy by day, quiet at night; Checkout's 95th percentile
    climbs well past its objective from 09:40 to 10:10, and its errors with it. */
function metrics(serviceId: string): MetricPoint[] {
  const index = SERVICES.findIndex((one) => one.id === serviceId);
  const service = SERVICES[index];
  if (service === undefined) throw new Error(`No service "${serviceId}".`);
  const r = random(1000 + index * 37);
  const points: MetricPoint[] = [];
  let drift = 0;
  for (let t = at(17, 0); t <= NOW; t += 5 * MINUTE) {
    const hour = (t - at(17, 0)) / (60 * MINUTE);
    const load = 0.35 + 0.65 * Math.max(0, Math.sin((Math.PI * (hour - 5)) / 16));
    drift = 0.8 * drift + (r() - 0.5) * 0.08;
    const base = service.latencySlo * (0.45 + 0.2 * load + drift);
    const surge = serviceId === "checkout" && t >= at(17, 9, 40) && t < at(17, 10, 10) ? 1.9 : 1;
    const p95 = Math.round(base * surge * (1 + r() * 0.08));
    points.push({
      t,
      p50: Math.round(p95 * (0.42 + r() * 0.06)),
      p95,
      errorRate: Math.round((0.05 + r() * 0.12 + (surge > 1 ? 2.4 + r() : 0)) * 100) / 100,
      requests: Math.round(VOLUME[service.tier] * load * (1 + (r() - 0.5) * 0.1)),
    });
  }
  return points;
}

const SERIES = [
  { name: "Checkout", data: metrics("checkout") },
  { name: "Billing", data: metrics("billing") },
  { name: "Reporting", data: metrics("reports") },
];

export default function HideASeries() {
  /* Reporting's slow jobs would flatten the other two. */
  const { Chart, XAxis, YAxis, Line, showOnly, showAllSeries } = useChart(SERIES[0]!.data, {
    initialView: { hidden: ["Reporting"] },
  });
  return (
    <>
      <div className="demo-actions">
        <button type="button" className="demo-button" onClick={() => showOnly("Checkout")}>
          Only Checkout
        </button>
        <button type="button" className="demo-button" onClick={showAllSeries}>
          Show all
        </button>
      </div>
      <Chart height={260} ariaLabel="Latency of three services">
        <XAxis value="t" time />
        <YAxis label="ms" />
        {SERIES.map((one) => (
          <Line key={one.name} data={one.data} value="p95" name={one.name} />
        ))}
        <Legend />
        <Tooltip mode="x" />
      </Chart>
    </>
  );
}

Legend above or below

A Legend stands above the plot by default, placement="bottom" puts it below; hovering an entry lifts its series.

import { Legend, useChart } from "@umriss-ui/charts";

/* Data from the controlling world, written out here so the example runs on its own. */
/** A small LCG - the same numbers on every computer. */
function random(seed: number): () => number {
  let s = seed >>> 0;
  return () => {
    s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
    return s / 4294967296;
  };
}

interface CostCentre {
  id: string;
  name: string;
  owner: string;
  /** The budget of one month. */
  monthlyBudget: number;
}

const COST_CENTRES: readonly CostCentre[] = [
  { id: "CC-1100", name: "Sales", owner: "Helen Marsh", monthlyBudget: 142_000 },
  { id: "CC-1200", name: "Marketing", owner: "Rafael Ortiz", monthlyBudget: 68_000 },
  { id: "CC-2100", name: "Engineering", owner: "Anika Sørensen", monthlyBudget: 188_000 },
  { id: "CC-2200", name: "Design", owner: "Paul Whitaker", monthlyBudget: 54_000 },
  { id: "CC-3100", name: "Customer service", owner: "Grace Obi", monthlyBudget: 61_000 },
  { id: "CC-4100", name: "Finance", owner: "Martina Vogel", monthlyBudget: 47_000 },
  { id: "CC-4200", name: "People", owner: "Daniel Frost", monthlyBudget: 39_000 },
  { id: "CC-4300", name: "IT", owner: "Kenji Arai", monthlyBudget: 83_000 },
  { id: "CC-4400", name: "Facilities", owner: "Olga Ivanova", monthlyBudget: 72_000 },
];

/** The year's months, as `"2026-01"` … `"2026-12"`. */
const MONTHS: readonly string[] = Array.from({ length: 12 }, (_, i) => `2026-${String(i + 1).padStart(2, "0")}`);

/** The months whose books are closed: they have an actual. */
const CLOSED_MONTHS = 2;

interface LedgerRow {
  costCentre: string;
  month: string;
  budget: number;
  /** `null` while the month is open. */
  actual: number | null;
  forecast: number;
}

/* Where a cost centre lands against its budget, as a factor: Marketing's
   spring campaign overspends, IT's delayed licences underspend. */
const TENDENCY: Readonly<Record<string, number>> = { "CC-1200": 1.14, "CC-4300": 0.88 };

/** Budget, actual and forecast of every cost centre in every month. */
const LEDGER: readonly LedgerRow[] = COST_CENTRES.flatMap((centre, c) => {
  const r = random(310 + c);
  const tendency = TENDENCY[centre.id] ?? 1;
  return MONTHS.map((month, m) => {
    /* December pays the bonuses, August is the quiet month. */
    const season = m === 11 ? 1.12 : m === 7 ? 0.9 : 1;
    const budget = Math.round(centre.monthlyBudget * season);
    const forecast = Math.round((budget * (tendency + (r() - 0.5) * 0.06)) / 100) * 100;
    const actual = m < CLOSED_MONTHS ? Math.round(forecast * (1 + (r() - 0.5) * 0.08)) : null;
    return { costCentre: centre.id, month, budget, actual, forecast };
  });
});

const MARKETING = LEDGER.filter((row) => row.costCentre === "CC-1200");
const MONTH_NAMES = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
/** `"2026-03"` as the axis writes it. */
const monthName = (month: string) => MONTH_NAMES[Number(month.slice(5)) - 1] ?? month;

function Marketing({ placement }: { placement?: "bottom" }) {
  const { Chart, XAxis, YAxis, Line } = useChart(MARKETING);
  return (
    <Chart height={220} ariaLabel={`Marketing's budget and forecast, legend ${placement ?? "above"}`}>
      <XAxis value="month" categoryLabel={monthName} />
      <YAxis label="€" />
      <Line value="budget" name="Budget" />
      <Line value="forecast" name="Forecast" />
      <Legend placement={placement} />
    </Chart>
  );
}

export default function LegendPlacement() {
  return (
    <div className="pair">
      <div>
        <p className="pair-caption">no placement - above, the default</p>
        <Marketing />
      </div>
      <div>
        <p className="pair-caption">placement="bottom"</p>
        <Marketing placement="bottom" />
      </div>
    </div>
  );
}

Keyboard

Key Action
Enter Space Hide the entry's series, or show it again.
Shift+Enter Show only the entry's series; on the only one visible, all again.

The keys of Chart apply here.

Accessibility

Every entry of a named series is a button whose aria-pressed says whether its series is shown, and the plot's summary names Shift+Enter. Where a click, a gesture or a setter would leave nothing visible and shows all instead, the chart's polite live region says so.

API

LegendProps

NameTypeDefaultDescription
placement"top" | "bottom""top"Which side of the plot area the legend stands on.

Shown in: Legend above or below, Mix series kinds (Chart), Mark every kind (Chart) and 28 more

Every page of @umriss-ui/charts

Getting started

Chart

Series

Limits and alarms

Around the chart

API index