Skip to content
KONIGI Patterns

Dashboards / Labels and context / Annotation

1 of 6 ›

Annotation

People need to compare changes in a chart with events that may help explain them.

Updated September 10, 2026

Problem

A chart shows when a metric changed, but the events that might explain it are recorded elsewhere.

Solution

Place events on the chart’s time axis with a marker and short label. Make details available by pointer, keyboard, or touch.

Use automatic events for records such as deployments and manual notes for findings people want to preserve. Let people filter event types so frequent markers do not cover the data.

Show a time span for an event with duration, such as maintenance. Keep the event’s source and timestamp available, and avoid presenting a nearby event as proof of what caused a change.

Use when

People use charts to investigate changes and need to compare them with recorded events.

Don’t use when

The event stream is high-volume and unfiltered. Forty markers is worse than none, because it converts the chart into a chart with a fence in front of it.

Trade-offs

Markers compete with the data and other chart guides. Nearby events can look like causes even when the connection is uncertain. Manual notes depend on people maintaining them, and stored annotations need to survive dashboard changes.

Checklist

  • Where do these events come from, and does anything guarantee they keep arriving?
  • Are annotations tagged, and does each panel show only the tags it needs?
  • At the busiest time range, how many markers appear, and is the chart still readable?
  • Is a span drawn as a span, or flattened into a line?
  • Does the marker say what happened, or only that something did?
  • Can the viewer get from a marker to the underlying deploy, ticket or incident?
  • Do annotations survive the dashboard being rebuilt or duplicated?
  • Do they compete visually with thresholds and bands on the same chart?
  • Does a marker near a spike risk implying a cause nobody has verified?
  • Who can add one, and is that the right set of people?

Compare

Grafana splits the pattern cleanly into stored annotations added by hand and query-driven ones from any data source, then filters both by tag, which is the most complete treatment and also the one that most needs someone to curate the tags. Datadog treats events as a first-class stream that overlays any graph, so deploys and alerts appear on charts without per-dashboard configuration, at the cost of needing suppression when the stream is noisy. Honeycomb leans on markers tied to deploys as the main annotation, on the reasoning that in practice the question is almost always whether a release caused it. Sentry goes further and makes the release an object rather than a marker, attaching regressions to it directly, which answers the causal question rather than only placing it on an axis.

Time series provides the time axis. Comment and annotate supports discussion of findings. Threshold line adds another chart reference.

Status history shows changes in state over time. Zoom and pan helps people inspect the period around an event.

Annotation anatomy A time series carrying two kinds of event marker: a vertical line with a label for a deploy, which is a moment, and a shaded band for a maintenance window, which is a span. Below, the same chart with every deploy of the day on it, which is a picket fence rather than a signal. Two kinds of event, on the data's own axis deploy 4a91c maintenance deploy incident config 1 2 3 1 POINT A deploy is a moment. A line, a marker and a label, with the detail on hover. 2 REGION A maintenance window or a degraded period is a span. Drawing it as a line loses the duration, which was the point. 3 TAGS What lets a panel choose the events it cares about. The same chart, every deploy of the day Automatic annotations are complete, and never stop arriving. Forty markers a day is a picket fence where a signal should be. Tags are the fix.
Wireframe — the pattern's anatomy, not any one product's version of it

Implementation

A marker saying why the line moved. Deploys, incidents and campaigns explain most of the shapes on a dashboard, and without them every reader reconstructs the cause from memory.

shadcn
npx shadcn@latest add toggle-group
npm
recharts
Tokens
--card--muted--muted-foreground--border--status-critical--status-warn--chart-1
13:2413:5714:3015:170150300450600deploy 4a91cmaintenance

AnnotatedChart.tsxRecharts with the events on the data's axis. A moment is a reference line, a span is a reference area, and the tag chips decide which arrive.

import { CartesianGrid, Line, LineChart, ReferenceArea, ReferenceLine, Tooltip, XAxis, YAxis } from "recharts";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import { cn } from "@/lib/utils";

/**
 * An event on the data's own axis. `to` is what separates a span from a
 * moment: a deploy has none, a maintenance window does, and the chart draws a
 * region for the second rather than flattening it into a line that loses the
 * duration.
 */
export type Annotation = {
  id: string;
  /** Epoch ms. */
  at: number;
  to?: number;
  /** What lets a panel choose the events it cares about. */
  tag: string;
  /** What happened, not only that something did. */
  label: string;
  /** The deploy, ticket or incident behind the marker. */
  href?: string;
};

export type Sample = { t: number; value: number };

const hhmm = (t: number) => {
  const d = new Date(t);
  return `${String(d.getUTCHours()).padStart(2, "0")}:${String(d.getUTCMinutes()).padStart(2, "0")}`;
};

const TICK = { fontSize: 10, fill: "hsl(var(--muted-foreground))" };
const POINT = "hsl(var(--chart-1))";
const REGION = "hsl(var(--status-warn))";

export function AnnotatedChart({
  data,
  annotations,
  tags,
  shown,
  onShownChange,
  width = 420,
  height = 170,
}: {
  data: Sample[];
  annotations: Annotation[];
  /** Every tag the source can deliver, in chip order. */
  tags: string[];
  /** The tags this panel shows. Forty deploy markers a day is a picket fence. */
  shown: string[];
  onShownChange: (tags: string[]) => void;
  width?: number;
  height?: number;
}) {
  const visible = annotations.filter((a) => shown.includes(a.tag));
  return (
    <div>
      <div className="overflow-x-auto">
        <LineChart width={width} height={height} data={data} margin={{ top: 8, right: 8, bottom: 0, left: -20 }}>
          <CartesianGrid vertical={false} stroke="hsl(var(--border))" />
          <XAxis interval={0} dataKey="t" type="number" domain={["dataMin", "dataMax"]} tickFormatter={hhmm} tick={TICK} stroke="hsl(var(--border))" />
          <YAxis domain={[0, "auto"]} tick={TICK} stroke="hsl(var(--border))" />
          <Tooltip labelFormatter={(t) => hhmm(Number(t))} formatter={(v) => [`${v} ms`, "p95"]} />
          {visible.map((a) =>
            a.to ? (
              <ReferenceArea key={a.id} x1={a.at} x2={a.to} fill={REGION} fillOpacity={0.12} stroke="none"
                label={{ value: a.label, position: "insideTop", fill: REGION, fontSize: 10 }} />
            ) : (
              <ReferenceLine key={a.id} x={a.at} stroke={POINT} strokeDasharray="4 3"
                label={{ value: a.label, position: "insideTopRight", fill: POINT, fontSize: 10 }} />
            ),
          )}
          <Line type="linear" dataKey="value" stroke={POINT} strokeWidth={2} dot={false} isAnimationActive={false} />
        </LineChart>
      </div>
      <ToggleGroup type="multiple" value={shown} onValueChange={onShownChange} className="mt-3 justify-start gap-2" aria-label="event tags">
        {tags.map((tag) => (
          <ToggleGroupItem key={tag} value={tag} size="sm"
            className={cn("h-6 rounded-full border px-2 text-[10px] data-[state=on]:bg-muted", tag === "incident" && "border-status-critical text-status-critical data-[state=on]:text-status-critical")}>
            {tag}
          </ToggleGroupItem>
        ))}
      </ToggleGroup>
      {/* The marker is a door, not a caption: the ticket is one click away. */}
      {visible.some((a) => a.href) && (
        <ul className="mt-2 flex flex-wrap gap-x-3 text-[10px] text-muted-foreground">
          {visible.filter((a) => a.href).map((a) => (
            <li key={a.id}><a href={a.href} className="underline">{hhmm(a.at)} {a.label}</a></li>
          ))}
        </ul>
      )}
    </div>
  );
}

demo.tsxHow it is called: the samples, two events, the tags the panel offers, and which are shown.

import { useState } from "react";
import { AnnotatedChart, type Annotation, type Sample } from "./AnnotatedChart";

/** Checkout p95 through an afternoon. Latency doubles at 14:07 and the deploy says why. */
const T0 = Date.UTC(2026, 8, 15, 13, 20);
const at = (min: number) => T0 + min * 60_000;

const DATA: Sample[] = [
  [4, 140], [17, 180], [30, 150], [43, 240], [47, 430], [57, 400],
  [70, 370], [83, 190], [96, 230], [109, 270], [117, 300],
].map(([m, value]) => ({ t: at(m), value }));

const EVENTS: Annotation[] = [
  { id: "d-4a91c", at: at(47), tag: "deploy", label: "deploy 4a91c", href: "/deploys/4a91c" },
  { id: "chg-2210", at: at(83), to: at(104), tag: "config", label: "maintenance", href: "/changes/2210" },
];

const TAGS = ["deploy", "incident", "config"];

export default function Demo() {
  const [shown, setShown] = useState(TAGS);
  return (
    <div className="rounded-lg border bg-card p-4">
      <AnnotatedChart data={DATA} annotations={EVENTS} tags={TAGS} shown={shown} onShownChange={setShown} />
    </div>
  );
}
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.

Demo / Annotations September 10, 2026 Grafana Play (signed out; no version string exposed) medium · dark · desktop-web
Frequent red event markers make the request-rate line difficult to follow across this twenty-four-hour view. The tag filter is already active, so filtering alone has not reduced the crowding enough. The event list below is easier to read because it separates timestamps and tags into individual entries. This example shows why event density matters as much as recording the events.
  • Annotation Frequent event markers compete with the request-rate line.
  • Annotation The event list provides readable timestamps and tags.
  • Filter bar A tag filter is already applied to the annotations.
  • Time-range picker The selected twenty-four-hour range affects how densely markers appear.
Show 1 more example Hide the rest

Kibana

A query bar and filtering controls define the data shown in charts and document tables.

Dashboards / [Flights] Global Flight Dashboard September 10, 2026 Elastic demo environment, sample flight data (guest session) dense · light · desktop-web
The delay table shows percentages without the number of flights behind each one. A high rate based on very few flights would look as prominent as one based on many. A nearby comparison tile names the previous week but does not clearly explain what its percentage represents. The filtering controls are more explicit: a query bar sits above pickers and a price range, giving people both typed and guided ways to narrow the view.
  • Ratio and rate Delay percentages appear without their flight counts.
  • Compare periods The comparison period is named, but the meaning of the percentage is unclear.
  • Share and embed Sharing and full-screen controls are available in the header.
  • Filter bar Pickers and a range control provide alternatives to typing a query.
  • Panel grid Panels use different sizes within a grid.
  • Stacked composition A 100% stack shows the mix of delay types without total volume.
  • Annotation Grouped event markers share the chart’s time axis.
  • Header KPI strip Different tile sizes and arrangements make the summary row less consistent.