Skip to content
KONIGI Patterns

Dashboards / Managing screen space / Multi-page dashboard

‹ 4 of 6 ›

Multi-page dashboard

People need separate views when a dashboard covers too many questions for one page.

Updated September 10, 2026

Problem

A dashboard contains several substantial questions that no longer fit clearly on one page.

Solution

Split the content into named pages with a consistent header and navigation. Each page should answer a distinct question at a clear scope.

Carry shared filters and time ranges between pages. Show exceptions explicitly so people do not compare results from different periods without realizing it.

Provide a useful landing page that directs people to the relevant area. Keep peer pages distinct from a drill-down hierarchy, where each step narrows the same subject.

Use when

The content divides into separate questions that each need enough space to justify a page.

Don’t use when

The pages are sequential steps of one investigation, where a hierarchy is honest and tabs will mislead. Also don’t split when the real problem is that one page has forty panels nobody pruned; the fix is deletion, not filing.

Trade-offs

Navigation hides content and makes side-by-side comparison harder. Shared state needs careful handling. Page sets can grow without clear ownership, leaving less-used pages outdated.

Checklist

  • Are these pages peers, or are they actually levels of a hierarchy?
  • Does the time range carry across pages?
  • Do filters and scope carry across pages?
  • Do all pages share header, control placement and layout conventions?
  • Is there a landing page that says which of the others to open?
  • Can a viewer tell which page they are on without reading the title?
  • Do links from alerts land on the right page, with scope intact?
  • What happens to a page nobody has opened in six months?
  • Is anything on two pages, and do the copies agree?
  • Would deleting panels remove the need to split at all?

Compare

Grafana has no built-in page set: multi-page is separate dashboards in a folder, linked by dashboard links or a variable, so state sharing is whatever the author wired through the URL. That flexibility is why Grafana estates sprawl and why search becomes the navigation. Datadog scopes pages by tags at the page level, so moving between dashboards in the same scope keeps the filter, which solves the defect Grafana leaves to the author. Sentry organises horizontally by concern—Issues, Performance, Releases—with project scope carried across, which is the pattern working as product structure rather than as dashboard configuration. Honeycomb collects saved queries into boards, so a “page” is a curated set of questions and the sharing problem is smaller because each query carries its own parameters. Cloudflare Radar is this pattern with no state to share: eleven sections behind one rail, no account, and two page-level controls in the entire product. Moving between pages costs nothing because there was almost nothing scoped to carry, which is the version of the problem every tool above is solving the hard way.

Tabs as genres divides pages by type of question. Collapsible row is a lighter way to separate sections. Sidebar and canvas supports persistent navigation.

Overview then detail organizes nested levels. Saved view preserves a person’s useful combination of settings.

Multi-page dashboard anatomy Four peer pages at the same scope, sharing a header that carries the time range and the filters. Beside them, a hierarchy for contrast: three levels of the same subject narrowing, which is a different pattern wearing similar clothes. Peers, not levels last 6h checkout Service · checkout Traffic Errors Latency Cost Overview · which of the four to open 1 2 3 For contrast Fleet Node Process Levels, not peers 1 SHARED STATE Time range and scope carry across. Set a range on one page, find a default six hours on the next, and nobody notices. 2 SHARED CHROME Same header, same filter placement, same time control. The value of a set is that learning one teaches you all of them. 3 THE LANDING PAGE A set with no obvious entry is a menu, and viewers open whichever they used last. One page says which of the others to open. Traffic and Errors are peers. Fleet and Node are levels. Conflating them makes navigation nobody can hold.
Wireframe — the pattern's anatomy, not any one product's version of it

Implementation

One page cannot hold it and the sections have different readers. Each page needs its own answer to is-it-okay at the top, because a reader who lands on page three never saw page one.

shadcn
npx shadcn@latest add tabs button dropdown-menu
npm
lucide-react
Tokens
--card--card-foreground--muted--muted-foreground--border--status-nominal--status-warn--status-critical

Service · checkout

MultiPageDashboard.tsxOne header and one range for every page, a status and headline every page has to supply, and a built-in overview that lays those answers side by side.

import type { ReactNode } from "react";
import { ChevronDown } from "lucide-react";
import { Button } from "@/components/ui/button";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { cn } from "@/lib/utils";

/** Closed. Every page answers is-it-okay in one of three words, so the
 *  overview can put the answers side by side. */
export type Status = "nominal" | "warn" | "critical";
const STATUS = { nominal: "text-status-nominal", warn: "text-status-warn", critical: "text-status-critical" };

/** What every page receives. A page cannot hold its own range, which is how
 *  two pages end up comparing different windows without anyone noticing. */
export type Shared = { range: string; scope: string };

export type Page = {
  id: string;
  title: string;
  status: Status;
  /** One line on whether it is okay. Printed at the top of the page and on
   *  its overview tile, so a reader landing here never needs page one. */
  headline: string;
  body?: (shared: Shared) => ReactNode;
};

export const RANGES = ["1h", "6h", "24h", "7d"] as const;

export function MultiPageDashboard({ subject, pages, shared, onShared, onScope, page, onNavigate }: {
  subject: string;
  /** Peers at one scope, differing in what they show. Levels of one subject
   *  narrowing are a different pattern. */
  pages: Page[];
  shared: Shared;
  onShared: (next: Shared) => void;
  /** Opens the app's scope picker. */
  onScope: () => void;
  /** "overview" or a page id. Lift it into the URL with the shared state, so
   *  an alert can link to the right page with the scope intact. */
  page: string;
  onNavigate: (id: string) => void;
}) {
  const current = pages.find((p) => p.id === page);

  return (
    <div className="rounded-lg border bg-card">
      {/* One header on every page: same title, same controls, same places. */}
      <div className="flex flex-wrap items-center gap-2 border-b px-4 py-2.5">
        <p className="text-sm text-card-foreground">{subject} · {shared.scope}</p>
        <DropdownMenu modal={false}>
          <DropdownMenuTrigger asChild>
            <Button variant="outline" size="sm" className="ml-auto h-6 px-2 text-[11px]">last {shared.range} <ChevronDown className="size-3" /></Button>
          </DropdownMenuTrigger>
          <DropdownMenuContent align="end" className="min-w-24">
            {RANGES.map((r) => <DropdownMenuItem key={r} className="text-xs" onSelect={() => onShared({ ...shared, range: r })}>last {r}</DropdownMenuItem>)}
          </DropdownMenuContent>
        </DropdownMenu>
        <Button variant="outline" size="sm" className="h-6 px-2 text-[11px]" onClick={onScope}>{shared.scope}</Button>
      </div>

      <Tabs value={page} onValueChange={onNavigate}>
        <TabsList className="h-9 w-full justify-start rounded-none border-b bg-transparent px-2">
          <TabsTrigger value="overview" className="text-xs data-[state=active]:shadow-none">Overview</TabsTrigger>
          {pages.map((p) => (
            <TabsTrigger key={p.id} value={p.id} className="gap-1.5 text-xs data-[state=active]:shadow-none">
              <span className={cn("text-[9px]", STATUS[p.status])} aria-hidden="true">●</span>{p.title}
            </TabsTrigger>
          ))}
        </TabsList>
      </Tabs>

      <div className="p-4">
        {current ? (
          <>
            <p className={cn("text-xs font-medium", STATUS[current.status])}>{current.status} · <span className="font-normal text-card-foreground">{current.headline}</span></p>
            <div className="mt-3">{current.body?.(shared)}</div>
          </>
        ) : (
          // The landing page. Its job is to say which of the others to open.
          <div className="grid gap-2" style={{ gridTemplateColumns: `repeat(${pages.length}, minmax(0, 1fr))` }}>
            {pages.map((p) => (
              <button key={p.id} type="button" onClick={() => onNavigate(p.id)} className="rounded border bg-muted p-3 text-left hover:border-foreground/40">
                <p className={cn("text-[10px] font-medium", STATUS[p.status])}>● {p.status}</p>
                <p className="mt-1 text-sm text-card-foreground">{p.title}</p>
                <p className="mt-1 text-[11px] leading-snug text-muted-foreground">{p.headline}</p>
              </button>
            ))}
          </div>
        )}
      </div>
    </div>
  );
}

demo.tsxHow it is called: Traffic, Errors, Latency and Cost for the checkout service, landing on the overview. Change the range and open any page.

import { useState } from "react";
import { MultiPageDashboard, type Page, type Shared } from "./MultiPageDashboard";

const figure = (label: string, value: string) => (
  <div className="rounded border bg-muted px-3 py-2">
    <p className="text-[10px] uppercase tracking-wide text-muted-foreground">{label}</p>
    <p className="text-lg tabular-nums text-card-foreground">{value}</p>
  </div>
);

/** Four peer pages about one service. Every body prints the range it was
 *  given, which is the only range there is. */
const PAGES: Page[] = [
  { id: "traffic", title: "Traffic", status: "nominal", headline: "1,080 req/s, up 3% on the window before.", body: (s) => <div className="grid grid-cols-2 gap-2">{figure(`requests, last ${s.range}`, "22.4M")}{figure("peak req/s", "1,112")}</div> },
  { id: "errors", title: "Errors", status: "nominal", headline: "0.04%, within the 0.5% budget.", body: (s) => <div className="grid grid-cols-2 gap-2">{figure(`5xx, last ${s.range}`, "8,960")}{figure("budget used", "8%")}</div> },
  { id: "latency", title: "Latency", status: "warn", headline: "p95 at 312ms after a 24ms rise; the 400ms page threshold is close.", body: (s) => <div className="grid grid-cols-2 gap-2">{figure(`p95, last ${s.range}`, "312ms")}{figure("p50", "88ms")}</div> },
  { id: "cost", title: "Cost", status: "nominal", headline: "£1,420 for the window, on the forecast line.", body: (s) => <div className="grid grid-cols-2 gap-2">{figure(`spend, last ${s.range}`, "£1,420")}{figure("forecast", "£1,400")}</div> },
];

/** Lands on the overview. The range you pick in the header is the range on
 *  every page, because there is only one. */
export default function Demo() {
  const [shared, setShared] = useState<Shared>({ range: "6h", scope: "checkout" });
  const [page, setPage] = useState("overview");
  return (
    <MultiPageDashboard
      subject="Service"
      pages={PAGES}
      shared={shared}
      onShared={setShared}
      onScope={() => {}}
      page={page}
      onNavigate={setPage}
    />
  );
}
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.

Cloudflare Radar

Public internet-traffic dashboards with location and time controls and links from summaries to detailed views.

Worldwide Overview September 11, 2026 Public site, signed out; no version string exposed medium · light · desktop-web
Radar’s overview uses location and time controls to set the scope of the page. Each summary panel links to a fuller section, making the page a starting point for further reading. Values are printed above the charts, so people can read figures without estimating them from marks. The bot and human shares, for example, are both labeled beside their bar.
Show 1 more example Hide the rest

Kibana

A query bar and filtering controls define the data shown in charts and document tables.

Dashboards / list September 10, 2026 Elastic demo environment (guest session) medium · light · desktop-web
The dashboard list has fourteen pages, with many similarly prefixed Kubernetes entries on the first page. Search helps people find a specific destination without browsing the full collection. Descriptions beneath titles provide useful context, while prefixes and tags group related entries. The list would also benefit from a clear indication of which dashboards are maintained or recommended.