Skip to content
KONIGI Patterns

Dashboards / Interaction / Cross-filter

‹ 2 of 8 ›

Cross-filter

People need to select data in one panel and inspect the same records in other panels.

Updated September 10, 2026

Problem

People want to select a group of data in one chart and see how that same group appears elsewhere on the dashboard.

Solution

Let a selection in one panel filter the other relevant panels. Keep the selection visible and show which filters are active.

Provide a clear way to undo the selection or reset the view. Distinguish a filtered view with no matches from a source with no data.

Use consistent selection behavior across chart types. Preserve the selection in shared views where supported so others can understand the same results.

Use when

Panels show different views of the same underlying records and people need to investigate a selected subset.

Don’t use when

The panels are independent. Cross-filtering unrelated queries produces panels that quietly show nothing, or worse, show something that looks like an answer. Also avoid where filtering is expensive enough that each selection costs seconds, because the pattern lives on immediacy.

Trade-offs

One selection can trigger many queries and slow the page. Hidden filter state makes results easy to misread. Gestures can conflict with scrolling or text selection, so the interaction needs clear cues.

Checklist

  • Is the active selection visible, in words, somewhere persistent?
  • Can it be cleared in one action, and is that action obvious?
  • Does every panel honour the selection, and do the ones that can’t say so?
  • How many queries does a single selection fire, and how long until the page settles?
  • Is a filtered-to-empty panel distinguishable from a broken one?
  • Does the selection go into the URL so it can be shared?
  • Is the interaction the same gesture across chart, table and map?
  • Can selections combine, and is the combination shown as AND?
  • Does a stale selection survive a time-range change, and should it?
  • What does a screenshot of this state fail to communicate?

Compare

Honeycomb turns the selection into analysis rather than only filtering: BubbleUp compares everything inside the drawn region against the baseline outside it and ranks the dimensions that differ, which answers “why” rather than only “what else”. Tableau made the pattern mainstream in BI, where selecting a mark filters every other sheet on the dashboard by default, and the default is exactly why analysts expect it everywhere else. Grafana has no general cross-filtering between panels; the equivalent runs through template variables that panels read from, which is explicit, shareable through the URL, and requires someone to wire it. Datadog scopes a whole page by tags so a selection changes the page’s context rather than each panel’s query, which keeps panels consistent at the cost of finer-grained selections.

Filter bar expresses the same narrowing through explicit controls. Drill-down opens a more detailed view. Heatmap provides a useful surface for region selection.

Zoom and pan focuses on a time range. Template variable applies shared choices across panels.

Cross-filter anatomy A region dragged on one chart, with the three panels beside it redrawn to describe only what was selected, and a chip above them naming the selection with one control to clear it. Point at the bump; everything else follows Requests by time 1 14:05–14:22 · 2,140 requests 3 4 By build By region By endpoint 2 1 THE SELECTION Brushing and linking. It asks no questions: the viewer doesn't need the name of the dimension, only to point at the bump. 2 EVERYTHING REDRAWS One dimension separates sharply from the others. The viewer supplied "this looks wrong"; the page supplied "because". 3 SAY IT'S FILTERED A page that has silently filtered itself will be screenshotted and misread. 4 ONE ACTION BACK Exploratory filtering is only exploratory if reversing it is free.
Wireframe — the pattern's anatomy, not any one product's version of it

Implementation

A selection in one panel narrows the rest. The whole pattern lives or dies on whether the reader can see that the other panels are now filtered, and undo it in one move.

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

Requests by time

13:5014:0214:1514:39
14:05–14:22 · 2,140 requests

By build

  • 4a91c1,757
  • 3f07e236
  • 2b1d9147

By region

  • eu-west812
  • us-east687
  • ap-south641

By endpoint

  • /checkout855
  • /cart726
  • /search559

CrossFilter.tsxDrag on the chart, the facets re-sum from the selected buckets, a chip says the selection in words and one control clears it.

import { useState } from "react";
import { Line, LineChart, ReferenceArea, XAxis, YAxis } from "recharts";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";

/** One interval of the series, with its count broken down by every facet. */
export type Bucket = { t: number; count: number; by: Record<string, Record<string, number>> };
export type Facet = { key: string; label: string };
/** Half-open: from is inside, to is not. */
export type Selection = { from: number; to: number };

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

/** Sum a facet over the buckets in play, largest first. */
const tally = (buckets: Bucket[], facet: string) => {
  const sums: Record<string, number> = {};
  for (const b of buckets) for (const [k, n] of Object.entries(b.by[facet] ?? {})) sums[k] = (sums[k] ?? 0) + n;
  return Object.entries(sums).sort((a, b) => b[1] - a[1]);
};

/**
 * Brushing and linking. The selection is controlled, so the page can put it in
 * the URL, and it is always shown in words with one control to clear it: a
 * page that has silently filtered itself will be screenshotted and misread.
 */
export function CrossFilter({ title, buckets, facets, selection, onSelectionChange }: {
  title: string;
  buckets: Bucket[];
  facets: Facet[];
  selection: Selection | null;
  onSelectionChange: (s: Selection | null) => void;
}) {
  const [anchor, setAnchor] = useState<number | null>(null);
  const inPlay = selection ? buckets.filter((b) => b.t >= selection.from && b.t < selection.to) : buckets;
  const total = inPlay.reduce((n, b) => n + b.count, 0);

  // Drag on the chart. The anchor is where the press landed; every move
  // redraws the region from there, and release commits it.
  const drag = (e: { activeLabel?: string | number }, phase: "down" | "move" | "up") => {
    const t = Number(e?.activeLabel);
    if (Number.isNaN(t)) return;
    if (phase === "down") { setAnchor(t); onSelectionChange(null); return; }
    if (anchor === null) return;
    const [from, to] = anchor < t ? [anchor, t] : [t, anchor];
    if (from !== to) onSelectionChange({ from, to: to + 60_000 });
    if (phase === "up") setAnchor(null);
  };

  return (
    <div className="grid grid-cols-[300px_1fr] gap-5">
      <div className="rounded-lg border bg-card p-4">
        <p className="border-b pb-2 text-[11px] uppercase tracking-wide text-muted-foreground">{title}</p>
        <div className="mt-2 cursor-crosshair select-none overflow-x-auto">
          <LineChart width={268} height={150} data={buckets} margin={{ top: 8, right: 4, bottom: 0, left: 4 }}
            onMouseDown={(e) => drag(e, "down")} onMouseMove={(e) => drag(e, "move")} onMouseUp={(e) => drag(e, "up")}>
            <XAxis interval={0} dataKey="t" type="number" domain={["dataMin", "dataMax"]} tickFormatter={hhmm} tick={{ fontSize: 10, fill: "hsl(var(--muted-foreground))" }} stroke="hsl(var(--border))" />
            <YAxis hide domain={[0, "auto"]} />
            {selection && (
              <ReferenceArea x1={selection.from} x2={selection.to} fill="hsl(var(--chart-1))" fillOpacity={0.15} stroke="hsl(var(--chart-1))" strokeWidth={2} />
            )}
            <Line type="linear" dataKey="count" stroke="hsl(var(--chart-1))" strokeWidth={2} dot={false} isAnimationActive={false} />
          </LineChart>
        </div>
      </div>

      <div className="flex flex-col gap-2.5">
        {selection && (
          <div className="flex items-center justify-between rounded-md border border-chart-1 bg-chart-1/10 py-1 pl-3 pr-1" role="status">
            <span className="text-[11px] tabular-nums text-card-foreground">
              {hhmm(selection.from)}–{hhmm(selection.to)} · {total.toLocaleString("en-US")} requests
            </span>
            <Button variant="ghost" size="sm" className="h-6 w-6 p-0 text-chart-1" onClick={() => onSelectionChange(null)} aria-label="clear selection">✕</Button>
          </div>
        )}
        {facets.map((f) => {
          const rows = tally(inPlay, f.key);
          const max = rows[0]?.[1] ?? 1;
          // One value taking two-thirds of the selection is the "because". Say so.
          const dominant = selection && rows[0] && rows[0][1] / total > 2 / 3 ? rows[0][0] : null;
          return (
            <div key={f.key} className="rounded-lg border bg-card p-2.5">
              <p className="text-[10px] uppercase tracking-wide text-muted-foreground">{f.label}</p>
              <ul className="mt-1.5 flex flex-col gap-1">
                {rows.map(([k, n]) => (
                  <li key={k} className="grid grid-cols-[64px_1fr_40px] items-center gap-2 text-[10px] tabular-nums">
                    <span className="truncate text-muted-foreground">{k}</span>
                    <span className={cn("h-2 rounded-[1px]", dominant ? (k === dominant ? "bg-chart-1" : "bg-muted") : "bg-chart-1")} style={{ width: `${(100 * n) / max}%` }} />
                    <span className="text-right text-muted-foreground">{n.toLocaleString("en-US")}</span>
                  </li>
                ))}
              </ul>
            </div>
          );
        })}
      </div>
    </div>
  );
}

demo.tsxHow it is called: per-minute buckets with their facet splits, and the selection held in state so it can go in the URL.

import { useState } from "react";
import { CrossFilter, type Bucket, type Selection } from "./CrossFilter";

/**
 * Requests per minute from 13:50, flat until a bump at 14:05 that one build
 * explains. Each minute carries its own split by build, region and endpoint,
 * so the facets can be re-summed for any selection.
 */
const T0 = Date.UTC(2026, 8, 15, 13, 50);
const BUMP_AT = 15;
const BUMP = [100, 108, 116, 124, 130, 136, 140, 142, 148, 142, 140, 136, 130, 124, 116, 108, 100];
const QUIET = [40, 44, 42, 46, 44, 48];

const split = (count: number, shares: Record<string, number>) => {
  const out: Record<string, number> = {};
  let left = count;
  const keys = Object.keys(shares);
  keys.forEach((k, i) => { out[k] = i === keys.length - 1 ? left : Math.round(count * shares[k]); left -= out[k]; });
  return out;
};

const BUCKETS: Bucket[] = Array.from({ length: 50 }, (_, m) => {
  const inBump = m >= BUMP_AT && m < BUMP_AT + BUMP.length;
  const count = inBump ? BUMP[m - BUMP_AT] : QUIET[m % QUIET.length];
  return {
    t: T0 + m * 60_000,
    count,
    by: {
      build: split(count, inBump ? { "4a91c": 0.82, "3f07e": 0.11, "2b1d9": 0.07 } : { "4a91c": 0.34, "3f07e": 0.33, "2b1d9": 0.33 }),
      region: split(count, { "eu-west": 0.38, "us-east": 0.32, "ap-south": 0.3 }),
      endpoint: split(count, { "/checkout": 0.4, "/cart": 0.34, "/search": 0.26 }),
    },
  };
});

const FACETS = [
  { key: "build", label: "By build" },
  { key: "region", label: "By region" },
  { key: "endpoint", label: "By endpoint" },
];

export default function Demo() {
  // Starts with the bump selected. Drag on the chart to move it; ✕ clears it.
  const [selection, setSelection] = useState<Selection | null>({ from: T0 + 15 * 60_000, to: T0 + 32 * 60_000 });
  return <CrossFilter title="Requests by time" buckets={BUCKETS} facets={FACETS} selection={selection} onSelectionChange={setSelection} />;
}
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.

Netdata

A large collection of system charts with per-chart controls and anomaly-rate views.

Anomalies / Anomaly advisor September 11, 2026 Netdata Agent, Anomaly advisor (public registry node, signed out) medium · dark · desktop-web
Netdata shows the share and count of measurements flagged as unusual in separate charts. This provides a compact overview across many metrics rather than drawing an expected range on each one. People then select a time period to see which metrics contributed. The empty detail panel explains that a selection is needed before results will appear.
  • Anomaly band A separate anomaly-rate chart summarizes many metrics.
  • Explain this metric Definitions appear directly beneath section headings.
  • Cross-filter Selecting a time period reveals the contributing metrics.
  • Empty state The empty panel explains which action will produce results.
  • Share and embed A report control is available above the charts.
Show 1 more example Hide the rest

Tableau Public

Published workbooks show how individual authors arrange charts and interactions in Tableau.

Shopify Customer Journey September 10, 2026 Tableau Public embed view; workbook published by Lovelytics medium · light · desktop-web
This Lovelytics workbook uses clickable metrics and bars to filter or reveal detail. Instructions explain these interactions because the marks do not clearly look interactive. Nine summary values compete for attention in a grid. The sales chart also has a mismatch between its monthly title and the displayed dates, while its truncated vertical scale emphasizes variation. These choices make labels and interaction cues especially important.
  • Cross-filter An instruction explains that metric tiles filter the dashboard.
  • Header KPI strip Nine values share a grid without a clear starting priority.
  • Time series The title and displayed period differ, and the vertical axis starts above zero.
  • Hover detail City details are described as available on hover.
  • Geo map with markers The map shows raw sales totals, which also reflect differences between populations.
  • Ranked list One much larger value leaves the remaining bars difficult to compare.