Skip to content
KONIGI

Dashboards / Product mechanics / Comment and annotate

3 of 10

Comment and annotate

Two people are looking at the same spike and need to talk about it in place.

Updated September 10, 2026

Problem

Two people are on a call looking at the same chart. One says “the spike”. There are three spikes. The conversation happens in a chat window, and next month somebody finds the chart again with none of what was said attached to it.

Solution

Let people attach words to a place on the dashboard. A comment on a panel, on a region of time, or on a specific point, visible to whoever opens it later.

The value is almost entirely retrospective, which is why the pattern is consistently underbuilt. In the moment, a call plus a screenshot works. Six weeks later, when the same shape recurs, the only thing that helps is the note somebody left saying what it turned out to be. This is institutional memory attached to the artefact rather than to a chat log, and chat logs are where dashboard knowledge usually goes to die.

The design question that decides whether it works is anchoring. A comment on a dashboard is nearly useless because the dashboard changes underneath it. A comment anchored to a time range and a panel keeps meaning something, because both parts of “what” and “when” survive.

The second question is who the audience is. There are two kinds of note and conflating them makes both worse. A message is addressed to a person now—“is this you?”—and should behave like a conversation, with notification and resolution. A record is addressed to whoever comes next, and should behave like documentation: durable, findable, and not cluttering the view once read.

Most implementations build the first and hope it serves as the second, which produces a dashboard with four years of resolved conversations on it.

The related distinction is with annotation proper: an annotation says an event happened, and a comment says what somebody thinks about it. A deploy marker is an annotation. “This spike was the deploy, and it was the migration not the code” is a comment.

Use when

The same charts are read repeatedly by a team, findings recur, and the interpretation is worth more than the data point.

Don’t use when

The conversation belongs somewhere with a workflow—an incident record, a ticket—and a comment on a panel would fragment it. Dashboards are a poor system of record and a good place to point at one.

Trade-offs

Comments clutter the surface they are attached to, and the clutter grows monotonically because nobody deletes them. They fragment discussion across the tool and the team’s chat, which usually means neither is complete. They need notification to be useful in the moment and notification is what turns a dashboard into another inbox. And anything anchored to a panel breaks when the panel is edited, so the note survives while its subject does not.

Checklist

  • What is a comment anchored to: dashboard, panel, time range, or point?
  • Does the anchor survive the panel being edited or moved?
  • Is this a message to someone now, or a record for later, and does the UI distinguish them?
  • Can a resolved conversation be cleared without losing the finding?
  • Is there notification, and does it create an inbox nobody wanted?
  • Can a comment be found later by searching, or only by returning to the panel?
  • Does the comment carry the state it was made against, including filters?
  • Who can see comments, and does that match who can see the data?
  • What happens to comments when the dashboard is duplicated?
  • Is anything pruning notes nobody has read in two years?

Compare

Grafana has no comment system and expects the annotation mechanism to carry it: a stored annotation has a text field and tags, so a note is an event with prose attached, which anchors to time reliably and has no notion of conversation. Datadog has notebooks and per-graph discussion, keeping the analysis and the commentary in one artefact, which is closer to the record use than the message use. Notion and Figma are the reference implementations for anchored commenting generally, and dashboards have borrowed the interaction without borrowing the resolution model that keeps it from accumulating. Incident tools solve the record half properly by making the timeline the artefact and the dashboard a link into it, which is usually the better division of labour.

Annotation is the event-marker sibling and the entry that covers time anchoring. Share and embed is what people use instead when commenting does not exist. Saved view is how a discussed state gets preserved. Auto-insight is the machine-generated version of the same column. Drill-down is what a comment should be able to point at, so a note can reference a specific thing rather than a region.

Comment and annotate anatomy A note anchored to both a panel and a span of time, so it still means something when the dashboard changes around it. Beside it, the two kinds of note that get conflated: a message addressed to somebody now, and a record addressed to whoever comes next. Anchored to a panel and a range Checkout P95 1 14:02-14:19 · checkout P95 This was the deploy, but it was the migration, not the code. priya · 6 weeks ago · a record 3 Is this you? resolve sam · just now 2 1 ANCHORING A comment on a dashboard is nearly useless, because the dashboard changes underneath it. A panel plus a range keeps both the what and the when. 2 A MESSAGE Addressed to a person now. Behaves like a conversation: notified, then resolved and out of the way. 3 A RECORD Addressed to whoever comes next. Durable and findable. Most products build the first and hope it serves as the second, and end up with four years of resolved conversations on the page.
Wireframe — the pattern's anatomy, not any one product's version of it

Implementation

Two people looking at the same spike, talking about it in place. A comment anchored to a timestamp survives the conversation; the same words in a chat thread are unfindable a month later.

shadcn
npx shadcn@latest add avatar textarea button
npm
recharts
Tokens
--card--popover--popover-foreground--muted-foreground--border--chart-1

Checkout P95

13:4013:5514:1014:380200400600800

14:02-14:19 · checkout P95

This was the deploy, but it was the migration, not the code.

Ppriya · 6 weeks ago · a record

Is this you?

Ssam · just now

PanelComments.tsxEvery note carries a panel and a range. A message resolves away; a record stays and is dated.

import { useState } from "react";
import { Line, LineChart, ReferenceArea, XAxis, YAxis } from "recharts";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";

/**
 * A note is anchored to a panel and a span of time, both required. A comment
 * on "the dashboard" is nearly useless because the dashboard changes
 * underneath it; the panel keeps the what and the range keeps the when.
 * `panel` is the panel's key, which outlives its title.
 */
export type Anchor = { panel: string; from: number; to: number };

/**
 * Two kinds, and the set is closed. A message is addressed to a person now:
 * notified, then resolved and out of the way. A record is addressed to
 * whoever comes next and stays. Building one and hoping it serves as the
 * other leaves four years of resolved conversations on the page.
 */
export type Note = {
  id: string;
  kind: "message" | "record";
  text: string;
  author: string;
  at: number;
  anchor: Anchor;
  resolved?: boolean;
};

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 ago = (t: number, now: number) => {
  const m = Math.round((now - t) / 60_000);
  if (m < 1) return "just now";
  if (m < 60) return `${m} min ago`;
  const h = Math.round(m / 60);
  if (h < 24) return `${h} h ago`;
  const d = Math.round(h / 24);
  if (d < 14) return `${d} days ago`;
  return `${Math.round(d / 7)} weeks ago`;
};

const LINE = "hsl(var(--chart-1))";

export function PanelComments({ panel, data, notes, now, onResolve, onAdd, width = 320, height = 130 }: {
  panel: string;
  data: Sample[];
  notes: Note[];
  /** Epoch ms. Pass one to render on a server. */
  now: number;
  onResolve: (id: string) => void;
  /** A reply is anchored to the same panel and range as the note it answers. */
  onAdd: (text: string, kind: Note["kind"], anchor: Anchor) => void;
  width?: number;
  height?: number;
}) {
  const [draft, setDraft] = useState("");
  const open = notes.filter((n) => !n.resolved);
  const anchor = open[0]?.anchor;

  return (
    <div className="grid gap-5 sm:grid-cols-[1.1fr_1fr]">
      <div className="rounded-lg border bg-card p-4">
        <p className="border-b pb-2 text-[11px] uppercase tracking-wide text-muted-foreground">{panel}</p>
        <div className="mt-2 overflow-x-auto">
          <LineChart width={width} height={height} data={data} margin={{ top: 8, right: 8, bottom: 0, left: -24 }}>
            <XAxis interval={0} dataKey="t" type="number" domain={["dataMin", "dataMax"]} tickFormatter={hhmm} tick={{ fontSize: 10 }} stroke="hsl(var(--border))" />
            <YAxis domain={[0, "auto"]} tick={{ fontSize: 10 }} stroke="hsl(var(--border))" />
            {anchor && <ReferenceArea x1={anchor.from} x2={anchor.to} fill={LINE} fillOpacity={0.1} stroke={LINE} strokeDasharray="4 3" />}
            <Line type="linear" dataKey="value" stroke={LINE} strokeWidth={2} dot={false} isAnimationActive={false} />
          </LineChart>
        </div>
      </div>

      <div className="flex flex-col gap-3">
        {open.map((n) => (
          <div key={n.id} className={`rounded-lg border bg-popover p-3 ${n.kind === "message" ? "border-dashed" : ""}`}>
            {n.kind === "record" && (
              <p className="mb-1.5 text-[11px] tabular-nums text-muted-foreground">
                {hhmm(n.anchor.from)}-{hhmm(n.anchor.to)} · {n.anchor.panel}
              </p>
            )}
            <p className="text-xs leading-relaxed text-popover-foreground">{n.text}</p>
            <p className="mt-2 flex items-center gap-2 text-[10px] text-muted-foreground">
              <Avatar className="size-4"><AvatarFallback className="text-[8px]">{n.author[0].toUpperCase()}</AvatarFallback></Avatar>
              {n.kind === "message" && (
                <Button variant="outline" size="sm" className="h-5 px-1.5 text-[10px]" onClick={() => onResolve(n.id)}>resolve</Button>
              )}
              {n.author} · {ago(n.at, now)}{n.kind === "record" && " · a record"}
            </p>
          </div>
        ))}
        {anchor && (
          <form className="flex flex-col gap-2" onSubmit={(e) => { e.preventDefault(); if (draft.trim()) { onAdd(draft.trim(), "message", anchor); setDraft(""); } }}>
            <Textarea value={draft} onChange={(e) => setDraft(e.target.value)} rows={1} placeholder={`Note on ${hhmm(anchor.from)}-${hhmm(anchor.to)}`} className="min-h-0 text-xs" />
            <Button type="submit" size="sm" variant="secondary" className="self-end" disabled={!draft.trim()}>Comment</Button>
          </form>
        )}
      </div>
    </div>
  );
}

demo.tsxHow it is called: one record from six weeks ago, one message from just now, both anchored to the spike.

import { useState } from "react";
import { PanelComments, type Anchor, type Note, type Sample } from "./PanelComments";

/**
 * Checkout P95 through an afternoon, with the 14:02-14:19 spike. Priya left a
 * record six weeks ago saying what it turned out to be; Sam has just asked
 * someone whether the shape recurring is theirs. Resolving Sam's message
 * takes it off the panel and leaves the record.
 */
const T0 = Date.UTC(2026, 6, 30, 13, 40);
const at = (min: number) => T0 + min * 60_000;
const NOW = Date.UTC(2026, 8, 15, 9, 0);

const DATA: Sample[] = [
  [0, 210], [8, 260], [16, 230], [22, 640], [30, 590], [39, 250], [47, 300], [58, 270],
].map(([m, value]) => ({ t: at(m), value }));

const SPIKE: Anchor = { panel: "checkout P95", from: at(22), to: at(39) };

const NOTES: Note[] = [
  { id: "n1", kind: "record", author: "priya", at: NOW - 42 * 86_400_000, anchor: SPIKE,
    text: "This was the deploy, but it was the migration, not the code." },
  { id: "n2", kind: "message", author: "sam", at: NOW - 20_000, anchor: SPIKE, text: "Is this you?" },
];

export default function Demo() {
  const [notes, setNotes] = useState(NOTES);
  const resolve = (id: string) => setNotes((ns) => ns.map((n) => (n.id === id ? { ...n, resolved: true } : n)));
  const add = (text: string, kind: Note["kind"], anchor: Anchor) =>
    setNotes((ns) => [...ns, { id: `n${ns.length + 1}`, kind, text, author: "you", at: NOW, anchor }]);
  return <PanelComments panel="Checkout P95" data={DATA} notes={notes} now={NOW} onResolve={resolve} onAdd={add} />;
}
What it renders. Identical markup in both panes, with only the token values changing.

Examples

No captures reference this pattern yet. Captures arrive product by product; see Products for what's in the gallery so far.