The Dashboard Pattern I Keep Reusing
Every dashboard ends up with the same structure: period selector, metric cards, primary table, detail panel. Here's why this pattern holds and how to use it.
Three different client projects over two years, three different domains — logistics, admin tooling, operations monitoring — and every dashboard ended up with the same four zones: a command bar with period selector, a metric strip, a primary data table, and a collapsible detail panel. Not because I was copying myself. Because this structure maps to how operators actually use dashboards: "What's the current state? How does it compare to before? What specifically needs attention? What are the details on that item?" Four questions, four zones.
Why this structure recurs
Dashboards fail when they try to answer too many questions at once, or when the layout doesn't match the mental sequence the user follows. The common failure modes: a dense grid of charts with no hierarchy, a table with no summary above it so you have to count rows to get a sense of scale, or a detail panel that's a modal so every drill-down loses table context.
The four-zone pattern works because it encodes a sequence:
- Command bar — Set the frame of reference. What time period? What filter? The period selector here changes what every other zone shows.
- Metric strip — Answer "how much." Four to six numbers that give a snapshot. Revenue, orders, exceptions, completion rate. Numbers users can hold in their head.
- Primary table — Answer "what specifically." The list of actual records that make up the metrics above. Sortable, filterable, paginated.
- Detail panel — Answer "tell me more." A slide-in panel (not a modal) showing the full record when a row is selected.
The panel being a slide-in rather than a modal matters architecturally: the table stays visible. The user can glance back at other rows without closing anything. This is always the right call for record-dense workflows.
The layout component
The structural skeleton in React is straightforward. All inter-zone state — the selected period, the selected row — lives in a context so any zone can read it without prop drilling:
type DashboardContextValue = {
period: "7d" | "30d" | "90d";
setPeriod: (p: "7d" | "30d" | "90d") => void;
selectedRowId: string | null;
setSelectedRowId: (id: string | null) => void;
};
const DashboardContext = createContext<DashboardContextValue | null>(null);
export function useDashboard() {
const ctx = useContext(DashboardContext);
if (!ctx) throw new Error("useDashboard must be used within DashboardProvider");
return ctx;
}
export function DashboardProvider({ children }: { children: React.ReactNode }) {
const [period, setPeriod] = useState<"7d" | "30d" | "90d">("30d");
const [selectedRowId, setSelectedRowId] = useState<string | null>(null);
return (
<DashboardContext.Provider value={{ period, setPeriod, selectedRowId, setSelectedRowId }}>
{children}
</DashboardContext.Provider>
);
}
The layout itself:
export function DashboardLayout({ children }: { children: React.ReactNode }) {
const { selectedRowId } = useDashboard();
return (
<div className="dashboard-root">
<div className={clsx("dashboard-main", selectedRowId && "panel-open")}>
{children}
</div>
</div>
);
}
// Usage
export function ShipmentsDashboard() {
return (
<DashboardProvider>
<DashboardLayout>
<CommandBar />
<MetricStrip />
<ShipmentsTable />
<ShipmentDetailPanel />
</DashboardLayout>
</DashboardProvider>
);
}
The panel-open class shifts the main content left to make room for the panel. CSS handles the transition; no JS animation needed.
The period selector
The period selector is the frame of reference for everything. When it changes, all queries refetch. Using TanStack Query, the period goes into every query key so cache invalidation is automatic:
function CommandBar() {
const { period, setPeriod } = useDashboard();
return (
<header className="command-bar">
<h1 className="sr-only">Shipments Dashboard</h1>
<PeriodSelector value={period} onChange={setPeriod} />
</header>
);
}
function PeriodSelector({ value, onChange }: { value: string; onChange: (v: any) => void }) {
const options = [
{ label: "Last 7 days", value: "7d" },
{ label: "Last 30 days", value: "30d" },
{ label: "Last 90 days", value: "90d" },
];
return (
<div role="group" aria-label="Time period">
{options.map((opt) => (
<button
key={opt.value}
aria-pressed={value === opt.value}
onClick={() => onChange(opt.value)}
className={clsx("period-btn", value === opt.value && "active")}
>
{opt.label}
</button>
))}
</div>
);
}
The query side:
function useMetrics() {
const { period } = useDashboard();
return useQuery({
queryKey: ["metrics", period], // period in key = auto-refetch on change
queryFn: () => fetchMetrics(period),
staleTime: 60_000,
});
}
Changing the period invalidates every query that uses it in its key. No manual cache clearing, no imperative refetch calls. The period is just part of the key.
Loading states for each zone
Each zone has a distinct loading state, because each zone fetches independently.
Metric strip: four card-shaped skeletons. Fixed shape — you always know there are four cards:
function MetricStrip() {
const { data, isPending } = useMetrics();
if (isPending) return <MetricStripSkeleton count={4} />;
return (
<div className="metric-strip">
<MetricCard label="Open Orders" value={data.openOrders} />
<MetricCard label="On-Time Rate" value={data.onTimeRate} format="percent" />
<MetricCard label="Exceptions" value={data.exceptions} />
<MetricCard label="Avg. Transit Days" value={data.avgTransitDays} />
</div>
);
}
Primary table: row skeletons that match the visible page size. Don't animate them with a pulse if the query is typically under 150ms — the flash is more jarring than a brief empty state:
function ShipmentsTable() {
const { period, setSelectedRowId } = useDashboard();
const { data, isPending } = useQuery({
queryKey: ["shipments", period],
queryFn: () => fetchShipments(period),
});
if (isPending) return <TableSkeleton rows={12} columns={5} />;
return (
<table>
{/* ... */}
<tbody>
{data.shipments.map((row) => (
<tr key={row.id} onClick={() => setSelectedRowId(row.id)}>
{/* cells */}
</tr>
))}
</tbody>
</table>
);
}
Detail panel: lazy-loaded. The panel component isn't mounted until a row is selected. When it is, it fetches its own data:
function ShipmentDetailPanel() {
const { selectedRowId, setSelectedRowId } = useDashboard();
if (!selectedRowId) return null;
return (
<aside className="detail-panel">
<button onClick={() => setSelectedRowId(null)} aria-label="Close panel">
✕
</button>
<Suspense fallback={<DetailPanelSkeleton />}>
<ShipmentDetail id={selectedRowId} />
</Suspense>
</aside>
);
}
The ShipmentDetail component fetches the full record and renders within Suspense. The panel skeleton shows while that fetch resolves. Closing the panel sets selectedRowId to null, which unmounts the component and cancels any in-flight requests.
Empty states per zone, not per page
This is where most dashboards get sloppy: a single "no data" state for the whole page when some zones might have data and others don't.
The metric strip with zero values is not the same as the metric strip failing to load. A table with zero results for the current period filter is not the same as a table error. Each zone needs its own empty state:
- Metric strip empty: show zeros, not skeletons.
0is a valid metric value. - Table empty: "No shipments in the last 30 days" — scoped to the period. Include a CTA if relevant: "Try a different time range."
- Detail panel empty: shouldn't happen if your row IDs are valid, but if the fetch 404s, show "Record not found" inside the panel, not a full-page error.
The responsive breakdown
On narrow viewports, the four-zone layout stacks vertically. The detail panel becomes a bottom sheet or a full-screen drawer rather than a side panel. The metric strip collapses to a horizontal scroll or a 2x2 grid.
The pattern that works: collapse the side panel to a drawer at the same breakpoint where the table switches from full columns to a card-per-row layout. These usually happen around the same width because both depend on horizontal space.
What makes dashboards fail
Not too little data — too little hierarchy. A dashboard that shows 20 charts in a masonry grid has no clear sequence. The user's eye has nowhere to start. The four-zone pattern enforces a reading order: summary first, specifics second, detail on demand. That constraint is a feature, not a limitation.
The other failure: metrics that don't connect to the table below them. If the "Exceptions" card shows 14, the table should be filterable to show those 14 exceptions. If clicking a metric card doesn't change the table state, you've broken the sequence. The metric is a number the user immediately wants to drill into. Make that path obvious.