Skip to content
KONIGI

Dashboards / Interaction / Compare periods

1 of 8

Compare periods

Today's line means nothing without last week's line under it.

Updated September 10, 2026

Problem

Traffic is down 30% this morning. That is either an incident or a Monday, and the current chart cannot tell the difference because it only shows now.

Solution

Draw the comparison period on the same axes. Same shape, same scale, offset in time, usually rendered ghosted or dashed so the current period stays dominant.

The comparison base is the entire design decision, and there are only a few honest choices.

Immediately previous period suits things without a cycle. Fine for a quarterly total, wrong for anything with a weekly rhythm, because yesterday was Sunday.

Same period last week is the workhorse for anything human-driven, since it aligns day of week and time of day, which are the two strongest cycles in most product data.

Same period last year aligns season and holiday, and misaligns day of week, which is why year-over-year charts have a characteristic weekly wobble that people mistake for signal.

None of them handle moving holidays, and every one of them breaks in the week after a clock change.

Grafana’s implementation of this is time shift, applied per panel relative to the dashboard’s range. That per-panel scoping is worth noticing, because it is also the trap: a dashboard where one panel is shifted and the others are not looks entirely normal and is comparing different things.

The rendering matters more than it seems. Two lines of equal weight produce a chart where the viewer has to keep checking which is which. The comparison should be quieter than the present—thinner, dashed, greyed—so the current period reads first and the past reads as context.

Use when

The metric has a cycle, the audience needs to judge whether today is unusual, and a single number’s delta is not enough because the shape matters.

Don’t use when

The underlying population changed. Comparing this week against last week is meaningless if a marketing campaign doubled acquisition in between, and the chart will look like a product improvement. Also skip it where an anomaly band already encodes expected range more compactly.

Trade-offs

Every comparison doubles the ink on the chart and roughly doubles the query cost. The base period is a choice that is rarely displayed, so two people can read the same chart under different assumptions. Comparisons invite over-reading of small differences—a 4% gap against last Tuesday is usually noise, and drawn as two visibly separated lines it does not look like noise. And clock changes, leap days and moving holidays produce annual artefacts that get explained as business events.

Checklist

  • What is the comparison base, and is it stated on the chart?
  • Does the base align day of week, or does it manufacture a weekly cycle?
  • Is the comparison rendered subordinate to the current period?
  • Is only some of the page shifted, and would anyone notice?
  • Did the underlying population change between the periods?
  • How are clock changes, leap days and moving holidays handled?
  • Is the difference also available as a number, not only as two lines?
  • Is a 4% gap distinguishable from normal variation, and does anything say so?
  • Does the comparison survive a time-range change, and does it rescale sensibly?
  • Would an anomaly band say the same thing with half the ink?

Compare

Grafana implements this as a per-panel time shift relative to the dashboard range, which is flexible and lets one panel silently compare a different window from its neighbours. Plausible and similar analytics tools make period comparison a page-level toggle, so every number and chart shifts together and the base is displayed once where it cannot be missed. Datadog offers explicit day-before, week-before and month-before overlays as graph options, naming the base in the UI rather than expressing it as an offset someone has to decode. Amplitude pushes past overlays into cohort comparison, which answers the population-changed objection this pattern otherwise has no response to. Cloudflare Radar draws the previous period as a dotted line on the same axis by default, with no control to turn it off. The comparison is how the chart is drawn rather than a feature a viewer has to find, which is the one arrangement where nobody forgets to switch it on.

Delta indicator is this comparison reduced to a single badge, and inherits every base-period problem. Time-range picker sets the current window the comparison is relative to. Cohort grid is the rigorous answer when the population itself changes between periods. Anomaly band encodes “normal for this hour” more compactly. Time series is the chart both periods are drawn on.

Compare periods anatomy One chart carrying this week solid and last week dashed and quieter, so the current period reads first. Below, the three honest comparison bases drawn against the same axis: the immediately previous period, the same period last week, and the same period last year. Same axes, same scale, offset in time this week same period last week 1 2 1 THE PRESENT Solid, full weight. It has to read first, every time. 2 THE COMPARISON Thinner, dashed, quieter. Two lines of equal weight make a chart the viewer has to keep re-checking. Three bases, against the same now NOW PREVIOUS LAST WEEK LAST YEAR a year ago a week ago yesterday now Previous suits things with no cycle. Last week aligns the two strongest cycles in product data. Last year aligns the season and misaligns the weekday, and that wobble is what people mistake for signal.
Wireframe — the pattern's anatomy, not any one product's version of it

Implementation

Last week's line under today's. The comparison series has to read as secondary or the chart looks like two equal metrics, and the label must say which period the ghost is.

shadcn
npx shadcn@latest add toggle-group
npm
recharts
Tokens
--foreground--card--muted--muted-foreground--border--chart-1
this weeksame period last week+10%

aligns day of week and time of day

ComparePeriods.tsxThree bases as a closed set, the past drawn quieter than the present, the base named in the legend and the gap given as a number.

import { Line, LineChart, XAxis, YAxis } from "recharts";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";

/**
 * The base is the whole design decision, and there are three honest ones.
 * Each aligns a different cycle, so the chart says which it is using and
 * what that aligns, rather than leaving "last period" to mean anything.
 */
export const BASES = {
  "1d": { pick: "yesterday",  legend: "same period yesterday",  aligns: "time of day; a Sunday against a Monday" },
  "7d": { pick: "a week ago", legend: "same period last week",  aligns: "day of week and time of day" },
  "1y": { pick: "a year ago", legend: "same period last year",  aligns: "the season; the weekday wobbles" },
} as const;
export type Base = keyof typeof BASES;

const PRESENT = "hsl(var(--chart-1))";
const PAST = "hsl(var(--muted-foreground))";

export function ComparePeriods({ label, current, comparison, base, onBaseChange, width = 400, height = 150 }: {
  /** The current period, as the legend names it. */
  label: string;
  current: number[];
  /** The same window shifted back by `base`, on the same scale. */
  comparison: number[];
  base: Base;
  onBaseChange: (base: Base) => void;
  width?: number;
  height?: number;
}) {
  const data = current.map((now, i) => ({ i, now, then: comparison[i] }));
  const sum = (xs: number[]) => xs.reduce((a, b) => a + b, 0);
  // The gap as a number. Two lines show a difference; only a figure says how big.
  const pct = Math.round(((sum(current) - sum(comparison)) / sum(comparison)) * 100);

  return (
    <div className="rounded-lg border bg-card p-4">
      <div className="overflow-x-auto">
        <LineChart width={width} height={height} data={data} margin={{ top: 8, right: 16, bottom: 0, left: 16 }}>
          <XAxis interval={0} dataKey="i" hide />
          <YAxis domain={[0, "auto"]} hide />
          {/* The past is thinner, dashed and grey so the present reads first.
              Equal weight makes a chart the viewer keeps re-checking. */}
          <Line type="linear" dataKey="then" stroke={PAST} strokeWidth={1.5} strokeDasharray="5 4" dot={false} isAnimationActive={false} />
          <Line type="linear" dataKey="now" stroke={PRESENT} strokeWidth={2.5} dot={false} isAnimationActive={false} />
        </LineChart>
      </div>

      <div className="mt-2 flex flex-wrap items-center gap-x-5 gap-y-2 text-xs">
        <span className="inline-flex items-center gap-1.5 text-foreground">
          <span className="h-0.5 w-5 bg-chart-1" />{label}
        </span>
        <span className="inline-flex items-center gap-1.5 text-muted-foreground">
          <span className="w-5 border-t border-dashed border-muted-foreground" />{BASES[base].legend}
        </span>
        <span className="tabular-nums text-foreground">{pct > 0 ? "+" : ""}{pct}%</span>

        <ToggleGroup type="single" value={base} onValueChange={(v) => v && onBaseChange(v as Base)}
          aria-label="Compare against" className="ml-auto gap-1">
          {(Object.keys(BASES) as Base[]).map((b) => (
            <ToggleGroupItem key={b} value={b} size="sm" className="h-6 px-2 text-xs data-[state=on]:bg-muted">
              {BASES[b].pick}
            </ToggleGroupItem>
          ))}
        </ToggleGroup>
      </div>
      <p className="mt-1 text-xs text-muted-foreground">aligns {BASES[base].aligns}</p>
    </div>
  );
}

demo.tsxHow it is called: this week against the same window a week back, with the other two bases a click away.

import { useState } from "react";
import { ComparePeriods, type Base } from "./ComparePeriods";

/**
 * This week against the same window shifted back. Last week is the default
 * because it aligns the weekday; the other two are there to show the same
 * chart re-based, with the gap recomputed.
 */
const THIS_WEEK = [32, 54, 44, 92, 82, 60, 76, 58, 68];
const SHIFTED: Record<Base, number[]> = {
  "1d": [30, 50, 48, 88, 78, 64, 70, 60, 62],
  "7d": [40, 58, 50, 84, 74, 52, 62, 44, 50],
  "1y": [28, 44, 40, 70, 66, 48, 58, 42, 46],
};

export default function Demo() {
  const [base, setBase] = useState<Base>("7d");
  return (
    <ComparePeriods
      label="this week"
      current={THIS_WEEK}
      comparison={SHIFTED[base]}
      base={base}
      onBaseChange={setBase}
    />
  );
}
What it renders. Identical markup in both panes, with only the token values changing.

Examples

Captures whose hotspots reference this pattern, grouped by product and dated. The dashed boxes are this pattern; hover any box for the note.

Cloudflare Radar

A public dashboard with no account, no filters worth the name, and an audience of journalists. Designed for people who will read one number and leave.

Cloudflare Radar — Worldwide Overview
Multi-page dashboard. Eleven sections behind one rail, with the open one expanded in place. Radar is a site of dashboards, not a dashboard. Compare periods. The dotted series is the previous seven days, drawn on the same axis rather than beside it. Delta indicator. Eight shares with their change under them, all of them fractions of a point, which tells you how still this data is. Overview then detail. Two summaries and an arrow each. The panel exists to tell you whether the full page is worth opening. Ratio and rate. Shares only, never counts, and both sides of the split are named so the denominator is never in doubt. Stacked composition. Four mitigation techniques to 100%. The bar is almost redundant next to the printed figures, which is the point. Ranked list. A top ten with no magnitudes at all. The rank is the whole finding.
Worldwide Overview September 11, 2026 Public site, signed out; no version string exposed medium · light · desktop-web
Radar is a dashboard for people who did not come to use a dashboard. The audience is journalists, researchers and the merely curious, nobody has an account, and the design follows from that in two ways worth copying. Two controls scope the whole page—where, and when—and neither is a filter in the sense the rest of this gallery means it; the only other selector on the page sits inside the traffic panel. Everything else that looks like a control is a link. Each panel is a standing summary of a section that has its own full page behind the arrow in its heading, so the overview works as a table of contents rather than a filtered view of one dataset. And almost every value is printed as text above the chart that encodes it: "Bot 57.9%, Human 42.1%" sits over the bar rather than inside it. You can read the number without reading the chart, which is the right trade when most of your readers will take one figure and leave.
  • Multi-page dashboard Eleven sections behind one rail, with the open one expanded in place. Radar is a site of dashboards, not a dashboard.
  • Compare periods The dotted series is the previous seven days, drawn on the same axis rather than beside it.
  • Overview then detail Two summaries and an arrow each. The panel exists to tell you whether the full page is worth opening.
  • Ratio and rate Shares only, never counts, and both sides of the split are named so the denominator is never in doubt.
  • Stacked composition Four mitigation techniques to 100%. The bar is almost redundant next to the printed figures, which is the point.
  • Ranked list A top ten with no magnitudes at all. The rank is the whole finding.
  • Delta indicator Eight shares with their change under them, all of them fractions of a point, which tells you how still this data is.
Show 2 more examples Hide the rest

Kibana

Query-first rather than panel-first: the search bar is the primary control and the charts are downstream of it, which inverts Grafana's arrangement.

Kibana — Dashboards / [Flights] Global Flight Dashboard
Panel grid. Twelve columns, and the biggest panel is a table rather than the headline chart. Size isn't carrying priority here. Ratio and rate. Delay rates up to 100% with no denominator anywhere. One flight and a thousand flights render identically. Stacked composition. Stacked to 100%, so the total is discarded on purpose and only the mix of delay types remains. Annotation. Event markers along the top of the series, numbered and grouped, on the data's own axis. Header KPI strip. Five tiles in three different sizes and two different layouts, so the row reads as five things. Compare periods. "vs 1 week earlier, 76.9%"—the comparison base is named and the expression isn't. Filter bar. Declared controls under the query bar: two pickers and a price range. Both mechanisms on screen at once. Share and embed. Share, export and full-screen in the header. Whether the range and filters travel with them is the whole question.
Dashboards / [Flights] Global Flight Dashboard September 10, 2026 Elastic demo environment, sample flight data (guest session) dense · light · desktop-web
Two things on this page are worth arguing with. The first is the table on the right, sorted by delay rate: Chicago/Rockford 100%, Syracuse 100%, Birmingham 75%. A hundred percent of flights delayed is either a catastrophe or one flight, and nothing in the table says which, because the denominator isn't a column. The cells are on a red ramp, so the two rows that are almost certainly a sample of one are the loudest thing in the panel. The second is the tile row: Delayed 25.2%, then beside it "Delayed vs 1 week earlier—76.9%". Seventy-six point nine percent of what? It could be last week's rate, it could be this week as a proportion of last week, it could be the change. Three different numbers, one label, and the tile picks whichever the query returned. What the page gets right is the filtering: a KQL bar for people who know the syntax and three declared controls underneath for people who don't, both visible at once.
  • Ratio and rate Delay rates up to 100% with no denominator anywhere. One flight and a thousand flights render identically.
  • Compare periods "vs 1 week earlier, 76.9%"—the comparison base is named and the expression isn't.
  • Share and embed Share, export and full-screen in the header. Whether the range and filters travel with them is the whole question.
  • Filter bar Declared controls under the query bar: two pickers and a price range. Both mechanisms on screen at once.
  • Panel grid Twelve columns, and the biggest panel is a table rather than the headline chart. Size isn't carrying priority here.
  • Stacked composition Stacked to 100%, so the total is discarded on purpose and only the mix of delay types remains.
  • Annotation Event markers along the top of the series, numbered and grouped, on the data's own axis.
  • Header KPI strip Five tiles in three different sizes and two different layouts, so the row reads as five things.

Netdata

Per-second charts, hundreds per node, with a per-chart anomaly ribbon instead of a band on the series.

Netdata — Metrics / System
Header KPI strip. Twelve tiles in two rows, in four different layouts. It reads as twelve things rather than one strip. Sidebar and canvas. The rail is the dashboard. A generated tree of every metric family the agent found. Gauge and dial. Arcs for disk reads and writes, where the maximum is invented—this is a rate, not a capacity. Dashboard builder. A query builder inline in the panel header: group by, aggregation, node and dimension count. Search across panels. 720 charts, so search is the navigation and the tree is the filing system nobody browses. Semantic grouping. Sections generated by the collector rather than chosen. Consistent, and about nothing in particular. Compare periods. Offered per chart rather than per dashboard, which is the scoping trap: one panel shifted, the rest not. Freshness indicator. Live 1, Stale 8. Most products would have drawn all nine and said nothing.
Metrics / System September 10, 2026 Netdata Agent v2.10.0-686-nightly (public registry node, signed out) dense · dark · desktop-web
Structurally the opposite of Grafana, and worth the comparison. Nobody built this page. The right rail says "showing 720 of total 720 charts" and the tree beneath it—System, Compute, Memory, Storage, Network, Hardware, Processes, then Apps, Users, Groups, O/S Services, and every application it found—is generated from what the agent collects. The canvas is the same hierarchy rendered downward, four levels deep, headings prefixed with dashes: System, then Compute, then CPU, then the chart. There is no editorial layer at all, which means nothing is missing and nothing is prioritised. The header also does something most products won't: it reports "Live 1, Stale 8" beside the node count, so eight of the nine machines behind this view are not currently reporting and the page says so rather than drawing their last known values.
  • Sidebar and canvas The rail is the dashboard. A generated tree of every metric family the agent found.
  • Search across panels 720 charts, so search is the navigation and the tree is the filing system nobody browses.
  • Header KPI strip Twelve tiles in two rows, in four different layouts. It reads as twelve things rather than one strip.
  • Gauge and dial Arcs for disk reads and writes, where the maximum is invented—this is a rate, not a capacity.
  • Semantic grouping Sections generated by the collector rather than chosen. Consistent, and about nothing in particular.
  • Freshness indicator Live 1, Stale 8. Most products would have drawn all nine and said nothing.
  • Dashboard builder A query builder inline in the panel header: group by, aggregation, node and dimension count.
  • Compare periods Offered per chart rather than per dashboard, which is the scoping trap: one panel shifted, the rest not.