People need a closer look at one panel without losing their place in the dashboard.
Updated September 10, 2026
Problem
A small panel reveals a change but lacks enough room to inspect it. People need more detail without losing their place in the dashboard.
Solution
Let people expand a panel while keeping its query, filters, and time range. Closing it should restore the previous layout and scroll position.
Choose a full-screen view for a chart that needs space, a side panel for a record, or an inline expansion when surrounding content must stay close. Make opening and closing work with keyboard and touch.
Explain any change in aggregation or detail level when the panel expands. The enlarged result should not appear to contradict the original without an explanation.
Use when
People occasionally need a closer view of a panel while keeping the dashboard as their starting point.
Don’t use when
The detail belongs to a different subject. That is drill-down, and dressing a navigation as an expansion makes back behave unexpectedly.
Trade-offs
Expansion can hide surrounding context or rearrange the page. The expanded state may need to be included in shared links. It should not excuse making the default panel too small to understand.
Checklist
Is closing exactly as cheap as opening?
Does closing restore scroll position and grid state?
Does the expanded view use the same query, interval and time range as the small one?
If it uses a finer interval, does anything explain the disagreement?
Full-screen, side panel or inline, and does the choice suit the content?
Does the expansion go into the URL so it can be shared?
Is it reachable by keyboard, and does focus return correctly on close?
What is the touch equivalent?
Is any panel too small to read in its default state, relying on this to be usable?
Does expanding trigger a fresh query, and how long does it take?
Compare
Grafana offers full-screen panel view from a keyboard shortcut or the panel menu, keeps the dashboard’s time range, and puts the view in the URL so an expanded panel is linkable, which is the detail most implementations miss. Sentry favours the side-panel form for events and traces, keeping the issue list visible, which suits a queue where the next item matters as much as the current one. Datadog uses full-screen with the surrounding scope preserved, so an expanded graph stays filtered to whatever the page was showing. Honeycomb barely needs the pattern, because the result of a query already occupies the page and there is no grid of small panels to escape from.
Related
Drill-down navigates to a more specific subject. Hover detail provides a smaller amount of extra information. Collapsible row applies expansion to a section.
Panel grid holds the surrounding layout. Two-pane list and detail keeps the detail view open beside a list.
Wireframe — the pattern's anatomy, not any one product's version of it
Implementation
A small panel becomes a big one for a moment. The expanded view must keep the same time range and the same series, or the reader compares two different things and does not notice.
DetailOnDemand.tsxSmall and large render from the same panel and the same range, so they cannot disagree. One button opens and closes, Esc closes, and focus goes back where it came from. Three forms behind one prop.
import { useEffect, useRef } from "react";import { Maximize2, X } from "lucide-react";import { Button } from "@/components/ui/button";import { cn } from "@/lib/utils";export type Form = "full-page" | "side-panel" | "inline";export type Panel = { id: string; title: string; unit: string; values: number[] };/** The one query every panel shares. Both sizes render from it, so * expanding cannot change the interval or the range. */export type Range = { label: string; interval: string };const FORM_LABEL: Record<Form, string> = { "full-page": "Full page", "side-panel": "Side panel", inline: "Inline" };/** One path, scaled to whatever box it is in. */function Line({ values, className }: { values: number[]; className?: string }) { const min = Math.min(...values), max = Math.max(...values), span = max - min || 1; const d = values.map((v, i) => `${i ? "L" : "M"}${(i / (values.length - 1)) * 100} ${100 - ((v - min) / span) * 100}`).join(" "); return ( <svg viewBox="0 0 100 100" preserveAspectRatio="none" className={cn("h-full w-full overflow-visible", className)} aria-hidden="true"> <path d={d} fill="none" stroke="hsl(var(--chart-1))" strokeWidth="1.5" vectorEffect="non-scaling-stroke" /> </svg> );}function PanelCard({ panel, range, large, onToggle, buttonRef }: { panel: Panel; range: Range; large: boolean; onToggle: () => void; buttonRef?: (el: HTMLButtonElement | null) => void;}) { const last = panel.values[panel.values.length - 1]; return ( <div className={cn("flex flex-col rounded-lg border bg-card p-3", large && "border-chart-1")}> <div className="flex items-center gap-2"> <p className="text-xs text-muted-foreground">{panel.title}</p> <p className="text-sm tabular-nums text-card-foreground">{last.toLocaleString("en-GB")}{panel.unit}</p> {/* The same button opens and closes; Esc closes too. */} <Button ref={buttonRef} variant="ghost" size="sm" className="ml-auto h-6 w-6 p-0" onClick={onToggle} aria-label={large ? `Close ${panel.title}` : `Expand ${panel.title}`}> {large ? <X className="size-3.5" /> : <Maximize2 className="size-3" />} </Button> </div> <div className={cn("mt-2 min-h-0 flex-1", large ? "min-h-[120px]" : "h-10")}><Line values={panel.values} /></div> {/* The range is printed at both sizes, from the same object, so the two views cannot disagree. */} <p className={cn("mt-2 tabular-nums text-muted-foreground", large ? "text-xs" : "text-[10px]")}> {range.label} · {range.interval}{large && ` · ${panel.values.length} points`} </p> </div> );}export function DetailOnDemand({ panels, range, form, expanded, onExpand, columns = 3 }: { panels: Panel[]; range: Range; form: Form; /** Which panel is open, or null. Lift it into the URL so the page a * colleague opens is the one you found. */ expanded: string | null; onExpand: (id: string | null) => void; columns?: number;}) { const buttons = useRef(new Map<string, HTMLButtonElement>()); const open = panels.find((p) => p.id === expanded) ?? null; useEffect(() => { if (!open) return; const onKey = (e: KeyboardEvent) => e.key === "Escape" && onExpand(null); window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); }, [open, onExpand]); // Closing puts focus back on the button that opened it, so keyboard users // land where they were rather than at the top of the page. const close = (id: string) => { onExpand(null); requestAnimationFrame(() => buttons.current.get(id)?.focus()); }; const grid = ( <div className="grid gap-3" style={{ gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))` }}> {panels.map((p) => { const large = form === "inline" && p.id === expanded; return ( <div key={p.id} className={cn(large && "col-span-full")}> <PanelCard panel={p} range={range} large={large} onToggle={() => (p.id === expanded ? close(p.id) : onExpand(p.id))} buttonRef={(el) => el && buttons.current.set(p.id, el)} /> </div> ); })} </div> ); return ( <div className="relative" data-form={form} aria-label={`Detail as ${FORM_LABEL[form]}`}> {form === "side-panel" && open ? ( <div className="grid grid-cols-[1fr_44%] gap-3"> {grid} <PanelCard panel={open} range={range} large onToggle={() => close(open.id)} /> </div> ) : grid} {/* In a page this is `fixed inset-0`; the preview keeps it inside the frame. */} {form === "full-page" && open && ( <div className="absolute inset-0 z-10 flex bg-background/95 p-2"> <div className="flex-1"><PanelCard panel={open} range={range} large onToggle={() => close(open.id)} /></div> </div> )} </div> );}export const FORMS = Object.entries(FORM_LABEL) as [Form, string][];
demo.tsxHow it is called: six service panels with p95 open as a side panel, and a picker to try the same state as full page or inline.
import { useState } from "react";import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";import { DetailOnDemand, FORMS, type Form, type Panel } from "./DetailOnDemand";/** Six panels for one service over the last six hours at five-minute * intervals. p95 latency is the one that moved. */const PANELS: Panel[] = [ { id: "rps", title: "Requests", unit: "/s", values: [812, 826, 840, 833, 851, 870, 902, 915, 931, 944, 960, 958, 971, 990, 1004, 1012, 1030, 1027, 1041, 1055, 1063, 1058, 1072, 1080] }, { id: "errors", title: "Error rate", unit: "%", values: [0.4, 0.4, 0.5, 0.4, 0.4, 0.5, 0.5, 0.4, 0.6, 0.5, 0.5, 0.4, 0.5, 0.6, 0.5, 0.5, 0.4, 0.5, 0.5, 0.6, 0.5, 0.5, 0.4, 0.5] }, { id: "p95", title: "p95 latency", unit: "ms", values: [212, 218, 209, 224, 231, 226, 240, 238, 252, 261, 274, 290, 318, 342, 361, 355, 338, 322, 301, 288, 276, 269, 262, 258] }, { id: "cpu", title: "CPU", unit: "%", values: [41, 42, 44, 43, 45, 47, 48, 50, 52, 53, 55, 58, 61, 63, 62, 60, 58, 57, 55, 54, 53, 52, 52, 51] }, { id: "mem", title: "Memory", unit: "%", values: [63, 63, 64, 64, 64, 65, 65, 65, 66, 66, 66, 67, 67, 67, 68, 68, 68, 68, 69, 69, 69, 69, 70, 70] }, { id: "queue", title: "Queue depth", unit: "", values: [12, 14, 11, 15, 18, 16, 21, 24, 28, 33, 41, 52, 68, 81, 77, 64, 51, 43, 36, 30, 25, 21, 18, 16] },];/** p95 open as a side panel. The picker tries the other two forms on the * same state; the range under every panel stays the same one. */export default function Demo() { const [form, setForm] = useState<Form>("side-panel"); const [expanded, setExpanded] = useState<string | null>("p95"); return ( <div className="flex flex-col gap-3"> <ToggleGroup type="single" value={form} onValueChange={(v) => v && setForm(v as Form)} aria-label="Form of expansion" className="justify-start"> {FORMS.map(([id, label]) => ( <ToggleGroupItem key={id} value={id} size="sm" className="h-7 rounded-md border text-xs data-[state=on]:border-chart-1 data-[state=on]:bg-chart-1/10 data-[state=on]:text-chart-1">{label}</ToggleGroupItem> ))} </ToggleGroup> <DetailOnDemand panels={PANELS} range={{ label: "Last 6 hours", interval: "5m" }} form={form} expanded={expanded} onExpand={setExpanded} /> </div> );}
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.
Trace / cart checkout September 11, 2026Honeycomb sandbox, public dataset (signed out)dense · light · desktop-web
The trace contains seventy-one spans across one checkout request. The getDiscounts operation occupies much of the total duration, with visible SELECT spans arranged sequentially beneath it. That sequence is a useful place to investigate repeated work, though the chart alone does not establish the right fix. A distribution panel places the selected span’s duration among other observations, helping people judge whether this request is typical.
Trace waterfallIndented bars show parent-child relationships and timing.
Trace waterfallSequential SELECT operations within getDiscounts are worth investigating.
Percentile summaryThe distribution marks this trace among other durations.
Detail on demandSelecting a span opens its fields beside the waterfall.
Discover / filebeat logs September 10, 2026Elastic demo environment (guest session; no version string exposed)dense · light · desktop-web
The view places a searchable field list beside a large table of documents, with a volume chart above. Search is useful for navigating the hundreds of available fields. The default Summary column combines many fields into each row, making quick comparisons difficult until people choose more focused columns. Pagination and sorting are visible, but the scope of each operation needs to be clear.