Skip to content
KONIGI Patterns

Dashboards / Dashboard features / Comment and annotate

‹ 3 of 10 ›

Comment and annotate

Teams need to discuss specific chart data and keep their findings attached to it.

Updated September 10, 2026

Problem

Discussions about a chart can lose their meaning when the exact point or time range is not recorded with them.

Solution

Attach comments to a specific panel, point, or time range. Preserve enough context for someone returning later to understand what was discussed.

Distinguish a conversation that needs a reply from a note meant to record a finding. Support notifications and resolution for discussion, and keep lasting notes easy to find without covering the chart.

Show when changes to a panel have made an old comment’s reference unclear. Link to the relevant detail or saved view where possible.

Use when

A team returns to the same charts and needs to preserve findings or discuss specific data.

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 accumulate and can clutter the dashboard. Discussion may be split between the chart and other messaging tools. Changes to panels or live data can make old comments misleading unless their original context is preserved.

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 marks recorded events. Share and embed provides ways to send a view to others. Saved view preserves a useful dashboard state.

Auto-insight adds machine-generated findings. Drill-down links a note to more specific evidence.

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 screenshots yet for this pattern. Browse Products to see other examples.