Skip to content
KONIGI

Dashboards / Interaction / Cross-filter

2 of 8

Cross-filter

Selecting something in one panel should narrow every other panel to it.

Updated September 10, 2026

Problem

There is a bump in one chart. The viewer wants to know what everything else was doing during that bump, for those particular requests, and the alternative is retyping a filter into six panels and hoping they agree.

Solution

Make a selection in one view act as a filter on all the others. Drag a region, click a bar, pick a slice, and the rest of the page redraws to describe only what was selected.

This is brushing and linking, one of the oldest ideas in information visualisation, and it maps onto two of Shneiderman’s seven tasks at once: filter, and relate. Its power is that it asks no questions. The viewer does not need to know the name of the dimension that explains the bump—they only need to be able to point at the bump.

Honeycomb’s BubbleUp is the most aggressive version of the idea. Draw a box around an anomalous region of a heatmap and it computes the distribution of every dimension inside the box against the baseline outside it, then ranks dimensions by how different they are. A selection works as a hypothesis as much as a filter. The viewer supplies the “this looks wrong” and the tool supplies the “because these requests all came from one build”.

Two design decisions decide whether an ordinary implementation is any good. First, is the selection visible after it is made—a page that has silently filtered itself is a page that will be screenshotted and misread. Second, can it be undone in one action, because exploratory filtering is only exploratory if reversing it is free.

Use when

The panels on a page describe the same underlying population from different angles, and the viewer’s question is “what else is true of these”.

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

Coordinated views multiply query load, so one drag can fire a dozen queries. State becomes invisible: the difference between “no data” and “filtered to nothing” is the whole page, and viewers who did not make the selection cannot tell. Sharing gets harder, because a screenshot of a cross-filtered dashboard omits the thing that makes it true. And the selection model has to be consistent across panel types, which is where most implementations fracture—drag means brush on a chart and text-select on a table.

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 is the same narrowing expressed as controls rather than gestures. Drill-down is the navigate-away alternative. Heatmap is the surface this pattern most often selects on. Zoom and pan is cross-filtering restricted to the time axis. Template variable is how Grafana approximates it, and the reason the selection ends up in the URL.

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:0514: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

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

Netdata

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

Anomalies / Anomaly advisor September 11, 2026 Netdata Agent, Anomaly advisor (public registry node, signed out) medium · dark · desktop-web
Netdata answers the anomaly problem without drawing a band at all, and the difference is worth recording. Rather than shading an expected range around each series, it scores every metric continuously and plots the result as its own series: the percentage of dimensions currently anomalous, and beneath it the count. Both sit near zero for most of the window and spike to about 0.04% at five separate moments. The trade is clear once you see it. A band tells you whether this metric is behaving, on the same axes as the metric, and needs one per chart. A rate tells you whether anything at all is behaving, in one chart, and cannot tell you which thing without a second step—which is what the panel at the bottom is for, and why it currently reads "You haven't highlighted any timeframe yet." The finding requires a brush selection before it will name a single metric.
  • Anomaly band Not a band. An anomaly rate as its own series, so one chart covers every metric instead of one band per chart.
  • Explain this metric Every section carries a sentence saying what it counts, directly under its heading rather than behind an icon.
  • Cross-filter Highlight a timeframe and the page names which metrics drove it. The selection is the query.
  • Empty state "You haven't highlighted any timeframe yet"—the reason for the blank, and the action that fills it.
  • Share and embed Generate report, top right. Whether the highlighted window travels with it is the question the button raises.
Show 1 more example Hide the rest

Tableau Public

Thousands of dashboards made by people who are not designers, published without a review step. The best available sample of what the pattern language looks like in the wild.

Shopify Customer Journey September 10, 2026 Tableau Public embed view; workbook published by Lovelytics medium · light · desktop-web
Tableau Public is the product; the design decisions here are the author's. This workbook was published by Lovelytics, so read it as what a competent analyst builds in Tableau rather than as how Tableau thinks dashboards should look. What is instructive is that it carries three separate lines of small-caps instruction—"click on metric to filter dashboard", "hover on a province to view breakdown by top 10 cities", "click on bar to view the second product purchased". Every interaction on the page needed a label, because none of them announces itself. That is the honest cost of cross-filtering: it is powerful and it is invisible until someone tells you it is there. Two other things. The tile block is nine values in a three-by-three grid, which is past the point where a strip has a reading order—the eye has to be told where to start and isn't. And the chart titled "Total sales per month" is plotting seven days, on a y-axis that begins at 500K, so a roughly twenty-five percent spread draws as a mountain range.
  • Cross-filter The tiles are the filter. It needed a line of instruction above it, because nothing about a number says it is clickable.
  • Header KPI strip Nine values in a grid rather than four to six in a row, so there is no privileged place for the eye to start.
  • Time series Titled per month, plotting seven days, on an axis starting at 500K. A 25% spread rendered as a cliff.
  • Hover detail The breakdown by city exists only on hover, so it is unavailable on touch and invisible in this screenshot.
  • Geo map with markers A choropleth of raw sales, unnormalised, so California and Texas lead partly by being large and populous.
  • Ranked list 490 against 30 for second place, so every bar below the first is a sliver and the ordering is all you get.