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.
Related
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.
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.
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.
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.
Worldwide Overview September 11, 2026Public site, signed out; no version string exposedmedium · 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.
Multi-page dashboardThe rail links to eleven sections and expands the current one.
Compare periodsThe dotted line shows the previous seven days on the same axes.
Overview then detailEach summary links to a fuller page through its heading arrow.
Ratio and rateBoth parts of the split are named and shown as percentages.
Stacked compositionFour mitigation categories fill a 100% bar, with their values printed above it.
Ranked listThis top-ten list shows rank without values.
Delta indicatorEach browser share has a change value beneath it.
Dashboards / list September 10, 2026Elastic 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.