Skip to content
KONIGI Patterns

Dashboards / Interaction / Drill-down

‹ 3 of 8 ›

Drill-down

People need to move from a summary to the records behind it while keeping their filters and time range.

Updated September 10, 2026

Problem

After spotting a problem in a summary, people need to reach the underlying records without rebuilding their filters and time range.

Solution

Make relevant chart marks or rows open a more detailed view of the selected item. Carry the time range, filters, and item identity into that view.

Show where the person is in the path and provide a reliable way back. Restore the previous view when they return.

Make links and their destinations clear. At the deepest level, show that the record has no further detail instead of implying another step is available.

Use when

A summary represents items that have useful, more specific views.

Don’t use when

There is no level below. A click that reloads the same information in a modal teaches viewers that clicking does nothing. Also avoid it when the answer is better reached by filtering in place; jumping pages costs orientation that cross-filtering doesn’t.

Trade-offs

Each step moves attention away from the overview. Deep paths can be hard to follow, and links to other products can lose context or break. One hierarchy may also fail to support a different investigation route.

Checklist

  • Does the time range survive the jump?
  • Do the active filters and template variables survive it?
  • Does the value or series the viewer clicked arrive as scope on the other side?
  • Is the target obviously the same thing, narrower—or does it look like a different page?
  • Is it visually clear what is clickable before the cursor is over it?
  • Does browser back return to the exact prior state, including scroll?
  • What happens at the bottom of the hierarchy, and does the last level say it is the last?
  • Does the destination handle an empty result, and does it say why?
  • Does it open in place or a new tab, and is that consistent across the dashboard?
  • Who owns the destination’s URL contract, and what happens when it changes?

Compare

Grafana makes the context explicit as interpolated variables, which is powerful and means a drill-down is only as good as the person who assembled the link string. Sentry builds the hierarchy into its object model—issue to event to trace to span—so drilling is navigation between things rather than between dashboards, and back always means something. Datadog ties panels to a scope inherited from the page, so a click carries tags rather than a hand-written URL, which rots less and constrains where you can go. Honeycomb largely replaces the pattern: rather than navigating to a narrower page you add a group-by or a filter to the query you already have, so there is no jump to lose context across.

Overview then detail organizes the path. Cross-filter narrows the current page. Detail on demand expands information in place.

Trace waterfall can be a detailed endpoint. Ranked list often provides the starting item.

Drill-down anatomy A bar being clicked on a chart scoped to one cluster over one hour, and the page it opens, which arrives carrying that cluster, that window, the series name and the value under the cursor. Beside it, the same click landing with none of it. What has to survive the click Errors · eu-west · 1h TimeoutError eu-west 14:00-15:00 4,182 1 2 All · 6h 3 1 EVERY AGGREGATE IS A DOOR A bar, a row, a cell, a node. Clicking opens the narrower view of the same thing, already scoped. 2 WHAT TRAVELS The window, the scope, the series name and the value under the cursor. That list is an inventory of what was in the viewer's head. 3 SILENT SCOPE LOSS Landing on all clusters at the default range. The number they were chasing isn't there, and they assume they misread the first chart. The other failure is the dead end: a level with nothing below it that doesn't say so.
Wireframe — the pattern's anatomy, not any one product's version of it

Implementation

Getting from the number that is wrong to the rows that made it wrong. The breadcrumb is the load-bearing part, because a reader three levels in has usually forgotten what the filter above them was.

shadcn
npx shadcn@latest add breadcrumb
Tokens
--card--card-foreground--accent--accent-foreground--muted-foreground--border--chart-1
eu-west14:00-15:004,182
  1. POST /checkout2,310
  2. GET /cart1,104
  3. POST /payments/authorize512
  4. GET /search256

Bottom of the hierarchy. Nothing here opens further.

DrillDown.tsxA tree walked by path. The scope and the clicked value travel as chips, the crumb goes back up, and the bottom level says it is the bottom.

import { Fragment, useState } from "react";
import {
  Breadcrumb, BreadcrumbItem, BreadcrumbLink, BreadcrumbList, BreadcrumbPage, BreadcrumbSeparator,
} from "@/components/ui/breadcrumb";
import { cn } from "@/lib/utils";

/** Every aggregate is a door. A node with children opens the narrower view
 *  of the same thing; a node without them is the bottom, and the level says so. */
export type Node = {
  name: string;
  value: number;
  children?: Node[];
};

/** What has to survive the click. The window and the cluster travel with
 *  every level, and the value under the cursor arrives as a chip. */
export type Scope = { cluster: string; from: Date; to: Date };

type Props = {
  root: Node;
  scope: Scope;
  /** Names from the root down to the level to open on. Put it in the URL, so
   *  back returns to the exact prior state. */
  defaultPath?: string[];
  onNavigate?: (path: string[]) => void;
};

const hhmm = (d: Date) => d.toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit", timeZone: "UTC" });
const span = (s: Scope) => `${Math.round((s.to.getTime() - s.from.getTime()) / 3_600_000)}h`;
const n = (v: number) => v.toLocaleString("en-US");

export function DrillDown({ root, scope, defaultPath = [], onNavigate }: Props) {
  const [path, setPath] = useState(defaultPath);
  const go = (next: string[]) => { setPath(next); onNavigate?.(next); };

  // Walk the path to the current node, keeping every ancestor for the crumbs.
  const trail = path.reduce<Node[]>((acc, name) => {
    const child = acc[acc.length - 1].children?.find((c) => c.name === name);
    return child ? [...acc, child] : acc;
  }, [root]);
  const here = trail[trail.length - 1];
  const rows = here.children ?? [];
  const max = Math.max(...rows.map((r) => r.value), 1);
  const bottom = rows.every((r) => !r.children);
  const rootLabel = `${root.name} · ${scope.cluster} · ${span(scope)}`;

  return (
    <div className="rounded-lg border bg-card p-4">
      <Breadcrumb>
        <BreadcrumbList className="text-[11px]">
          {trail.map((node, i) => {
            const last = i === trail.length - 1;
            const label = i === 0 ? rootLabel : node.name;
            return (
              <Fragment key={node.name}>
                <BreadcrumbItem>
                  {last ? (
                    <BreadcrumbPage className="uppercase tracking-wide">{label}</BreadcrumbPage>
                  ) : (
                    <BreadcrumbLink asChild>
                      <button type="button" className="uppercase tracking-wide" onClick={() => go(path.slice(0, i))}>{label}</button>
                    </BreadcrumbLink>
                  )}
                </BreadcrumbItem>
                {!last && <BreadcrumbSeparator />}
              </Fragment>
            );
          })}
        </BreadcrumbList>
      </Breadcrumb>

      {/* The scope arrived with the click. Cluster, window, and the number the
          viewer was chasing, so it is visibly still here. */}
      <div className="mt-3 flex flex-wrap gap-2 border-t pt-3">
        {[scope.cluster, `${hhmm(scope.from)}-${hhmm(scope.to)}`, n(here.value)].map((chip) => (
          <span key={chip} className="rounded-md border border-chart-1 bg-chart-1/10 px-2 py-0.5 text-[10px] tabular-nums text-card-foreground">{chip}</span>
        ))}
      </div>

      <ol className="mt-3 space-y-1.5">
        {rows.map((r) => {
          const door = !!r.children;
          const Tag = door ? "button" : "div";
          return (
            <li key={r.name}>
              <Tag
                type={door ? "button" : undefined}
                onClick={door ? () => go([...path, r.name]) : undefined}
                className={cn("grid w-full grid-cols-[minmax(0,1fr)_120px_56px] items-center gap-3 rounded px-1 py-0.5 text-left text-xs",
                  door && "hover:bg-accent hover:text-accent-foreground")}
              >
                <span className={cn("truncate", door && "underline decoration-border underline-offset-2")}>{r.name}</span>
                <span className="h-2 rounded-[1px] bg-chart-1" style={{ width: `${(r.value / max) * 100}%` }} />
                <span className="text-right tabular-nums text-muted-foreground">{n(r.value)}</span>
              </Tag>
            </li>
          );
        })}
      </ol>

      {bottom && <p className="mt-3 text-[11px] text-muted-foreground">Bottom of the hierarchy. Nothing here opens further.</p>}
    </div>
  );
}

demo.tsxHow it is called: errors by class then by endpoint, opened on the TimeoutError bar with eu-west and the hour still attached.

import { DrillDown, type Node } from "./DrillDown";

/**
 * Errors in eu-west over one hour, by class, then by endpoint. The demo lands
 * one level down, on the TimeoutError bar that was clicked, with the cluster,
 * the window and the 4,182 still attached. The crumb goes back up; the
 * endpoints are the bottom and say so.
 */
const ERRORS: Node = {
  name: "Errors",
  value: 8_582,
  children: [
    {
      name: "TimeoutError",
      value: 4_182,
      children: [
        { name: "POST /checkout", value: 2_310 },
        { name: "GET /cart", value: 1_104 },
        { name: "POST /payments/authorize", value: 512 },
        { name: "GET /search", value: 256 },
      ],
    },
    { name: "ConnectionReset", value: 1_960, children: [{ name: "GET /cart", value: 1_402 }, { name: "GET /search", value: 558 }] },
    { name: "UpstreamUnavailable", value: 1_410, children: [{ name: "POST /payments/authorize", value: 1_410 }] },
    { name: "RateLimited", value: 720, children: [{ name: "GET /search", value: 720 }] },
    { name: "Other", value: 310, children: [{ name: "GET /health", value: 310 }] },
  ],
};

export default function Demo() {
  return (
    <div className="max-w-[420px]">
      <DrillDown
        root={ERRORS}
        scope={{ cluster: "eu-west", from: new Date("2026-09-15T14:00:00Z"), to: new Date("2026-09-15T15:00:00Z") }}
        defaultPath={["TimeoutError"]}
      />
    </div>
  );
}
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.

Grafana

Examples of panel grids, shared selectors, stat tiles, and chart configuration.

Examples / Alert List September 10, 2026 Grafana Play (signed out; no version string exposed) medium · dark · desktop-web
This list shows seven alerts with their states and durations. Several have been firing for days, which raises a question about whether the rules still prompt a useful response. The names use inconsistent conventions and often do not describe the affected system clearly. Pending and firing states are labeled separately, and each row links back to its rule.
Show 1 more example Hide the rest

Grafana

Examples of panel grids, shared selectors, stat tiles, and chart configuration.

Examples / Logs Panel September 10, 2026 Grafana Play (signed out; no version string exposed) medium · dark · desktop-web
A stacked volume chart sits above the individual log events, linking the overall pattern to specific lines. Each row shows a local timestamp and a UTC timestamp, but their timezones are not clearly labeled. That can make the same instant look like two different times. Expanded event details include a trace ID, providing a route to the request behind the message.
  • Stacked composition Stacked log counts show volume by level over time.
  • Log tail Log rows are truncated and contain timestamps in two timezones.
  • Drill-down The trace ID connects the log event to its request.