Skip to content
KONIGI Patterns

Dashboards / Page layout / Single-column narrative

‹ 4 of 5 ›

Single-column narrative

Occasional readers need a clear path through a dashboard’s main findings.

Updated September 10, 2026

Problem

Occasional readers need a clear account of how things are going without deciding which panel to inspect first.

Solution

Arrange the page in a deliberate reading order: a short summary, the main chart, and supporting breakdowns. Give each section a clear purpose and remove detail that interrupts the main task.

Keep the content at a readable width on large screens. Use simple controls when they let people explore without rebuilding the page.

Provide direct access to lower sections if the page grows. Review the order as new content is added so the main answer stays easy to reach.

Use when

The page supports a clear sequence of questions and serves people who benefit from guidance.

Don’t use when

Viewers are comparing across sections. Anything that needs two things visible at once fails in a column, and that is most investigation work. Also poor where different roles need different things, since a single sequence serves the first audience and inconveniences the rest.

Trade-offs

A single column limits side-by-side comparison and may require more scrolling. Experts can find the guided order slow. Long pages still need priorities and navigation; stacking every panel does not create a useful narrative.

Checklist

  • Is there a real argument here, and does the order carry it?
  • Does the first screen answer the question most readers arrive with?
  • What is the reading time, and does it match the audience’s attention?
  • How many sections before this becomes a scroll rather than a sequence?
  • Is anything below the fold that matters, and does anything signal it is there?
  • Can a repeat visitor skip to what they came for?
  • How wide do charts get on a large screen, and are they still readable?
  • Is there any configuration, and could it be removed?
  • Who owns the order, and what is the process for adding a section?
  • Would an expert user find this slower than a grid?

Compare

Plausible is the clearest example, and the design decision worth stealing is making the summary row a control so choosing a metric and reading it are the same action. Public status pages run the same shape for the same reason: an unknown reader with no context needs one obvious order and no options. Grafana can approximate it with full-width panels stacked vertically, which works and gives up nothing structural, since a grid one column wide is a column. Google Analytics is the counter-example most people have used, having moved steadily toward configurable multi-panel reports and, in doing so, toward requiring expertise its casual audience does not have.

Panel grid supports more parallel reading. Header KPI strip often supplies the opening summary. Mobile adaptation commonly uses a single column.

Dense small-multiple layout serves expert scanning. Auto-insight can add a short explanation of important findings.

Single-column narrative anatomy One column read top to bottom: a metric row that doubles as the chart's control, the main chart, the breakdowns beneath it, then the detail table. Position carries the sequence, and the sequence carries the argument. One column, one reading order Visitors 42.1k Views 96.4k Bounce 38% Time 2m 4s 1 2 3 4 1 THE ROW IS THE CONTROL Selecting a metric changes what the chart below plots. That collapses configuration into reading: one gesture, and it's the same gesture as paying attention. 2 THE HEADLINE The shape behind the number in the row above it. Nothing else competes. 3 THE DECOMPOSITION What the headline breaks into. A grid has no reading order, so the viewer supplies one, and a non-expert supplies a bad one. 4 THE DETAIL Last, because it's the answer to a question the three sections above have raised. In a grid a marginal panel costs a corner. In a column it costs the reader's place.
Wireframe — the pattern's anatomy, not any one product's version of it

Implementation

One obvious reading order and nothing to configure. Each section states its finding in a sentence above the chart, so a reader who does not read charts still leaves with the answer.

npm
recharts
Tokens
--card--card-foreground--muted--muted-foreground--border--chart-1

Sources

  • Google18,400
  • Direct12,900
  • Newsletter7,800

Devices

  • Mobile22,700
  • Desktop15,100
  • Tablet4,300
PageVisitors
/pricing9,340
/blog/launch-week7,120
/docs/getting-started5,860

NarrativeReport.tsxHeadline, shape, decomposition, detail, in that order. The metric row is the one control, and it drives the chart.

import { useState } from "react";
import { Line, LineChart, YAxis } from "recharts";
import { cn } from "@/lib/utils";

export type Metric = {
  key: string;
  label: string;
  /** The headline figure, already formatted: "42.1k", "38%", "2m 4s". */
  value: string;
  /** One point per day in the window. The chart plots whichever is selected. */
  series: number[];
};
export type Breakdown = { title: string; rows: { label: string; value: number }[] };
export type Detail = { columns: [string, string]; rows: [string, string][] };

type Props = {
  metrics: Metric[];
  breakdowns: Breakdown[];
  detail: Detail;
  /** Which metric the chart opens on. Defaults to the first. */
  defaultMetric?: string;
  onSelect?: (key: string) => void;
};

/**
 * One column in the order the argument runs: headline, shape, decomposition,
 * detail. The metric row is the only control on the page, and selecting a
 * metric is the same gesture as reading it.
 */
export function NarrativeReport({ metrics, breakdowns, detail, defaultMetric, onSelect }: Props) {
  const [selected, setSelected] = useState(defaultMetric ?? metrics[0]?.key);
  const metric = metrics.find((m) => m.key === selected) ?? metrics[0];
  const pick = (key: string) => { setSelected(key); onSelect?.(key); };

  return (
    <div className="mx-auto flex w-full max-w-[380px] flex-col gap-3">
      <div className="grid grid-cols-4 divide-x rounded-lg border bg-card" role="tablist" aria-label="Metric">
        {metrics.map((m) => (
          <button
            key={m.key}
            role="tab"
            aria-selected={m.key === metric.key}
            onClick={() => pick(m.key)}
            className={cn("px-3 py-2.5 text-left", m.key === metric.key && "shadow-[inset_0_-2px_0_hsl(var(--chart-1))]")}
          >
            <span className="block text-[10px] text-muted-foreground">{m.label}</span>
            <span className={cn("mt-0.5 block text-base tabular-nums", m.key === metric.key ? "text-card-foreground" : "text-muted-foreground")}>{m.value}</span>
          </button>
        ))}
      </div>

      <div className="overflow-x-auto rounded-lg border bg-muted p-3">
        <LineChart width={320} height={80} data={metric.series.map((value) => ({ value }))} margin={{ top: 4, right: 0, bottom: 0, left: 0 }}>
          <YAxis hide domain={["dataMin - 5", "dataMax + 5"]} />
          <Line dataKey="value" stroke="hsl(var(--chart-1))" strokeWidth={2} dot={false} isAnimationActive={false} />
        </LineChart>
      </div>

      <div className="grid grid-cols-2 gap-3">
        {breakdowns.map((b) => {
          const max = Math.max(...b.rows.map((r) => r.value), 1);
          return (
            <div key={b.title} className="rounded-lg border bg-muted p-3">
              <p className="text-[10px] uppercase tracking-wide text-muted-foreground">{b.title}</p>
              <ul className="mt-2 flex flex-col gap-1.5">
                {b.rows.map((r) => (
                  <li key={r.label} className="flex items-center gap-2 text-[10px]">
                    <span className="h-2 bg-chart-1" style={{ width: `${(r.value / max) * 60}%` }} aria-hidden="true" />
                    <span className="truncate text-card-foreground">{r.label}</span>
                    <span className="ml-auto tabular-nums text-muted-foreground">{r.value.toLocaleString("en-GB")}</span>
                  </li>
                ))}
              </ul>
            </div>
          );
        })}
      </div>

      <table className="w-full rounded-lg border bg-muted text-[10px]">
        <thead>
          <tr className="border-b text-left uppercase tracking-wide text-muted-foreground">
            <th className="px-3 py-1.5 font-normal">{detail.columns[0]}</th>
            <th className="px-3 py-1.5 text-right font-normal">{detail.columns[1]}</th>
          </tr>
        </thead>
        <tbody>
          {detail.rows.map(([a, b]) => (
            <tr key={a}>
              <td className="px-3 py-1 text-card-foreground">{a}</td>
              <td className="px-3 py-1 text-right tabular-nums text-muted-foreground">{b}</td>
            </tr>
          ))}
        </tbody>
      </table>
    </div>
  );
}

demo.tsxHow it is called: four metrics over seven days, opened on Visitors, two breakdowns and a three-row detail.

import { NarrativeReport } from "./NarrativeReport";

/**
 * Seven days of a site. Visitors is selected, so the chart shows the week
 * that adds up to 42.1k; picking Bounce or Time redraws it.
 */
export default function Demo() {
  return (
    <NarrativeReport
      metrics={[
        { key: "visitors", label: "Visitors", value: "42.1k", series: [2070, 3450, 2760, 6900, 5870, 10000, 11040] },
        { key: "views", label: "Views", value: "96.4k", series: [4740, 7900, 6320, 15800, 13440, 22900, 25300] },
        { key: "bounce", label: "Bounce", value: "38%", series: [44, 41, 42, 37, 38, 36, 35] },
        { key: "time", label: "Time", value: "2m 4s", series: [110, 118, 115, 128, 124, 131, 134] },
      ]}
      breakdowns={[
        { title: "Sources", rows: [{ label: "Google", value: 18400 }, { label: "Direct", value: 12900 }, { label: "Newsletter", value: 7800 }] },
        { title: "Devices", rows: [{ label: "Mobile", value: 22700 }, { label: "Desktop", value: 15100 }, { label: "Tablet", value: 4300 }] },
      ]}
      detail={{
        columns: ["Page", "Visitors"],
        rows: [["/pricing", "9,340"], ["/blog/launch-week", "7,120"], ["/docs/getting-started", "5,860"]],
      }}
    />
  );
}
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.

Plausible Analytics

A metric row controls the main chart, followed by focused traffic breakdowns.

Live demo / plausible.io September 10, 2026 Plausible live demo, plausible.io's own stats (signed out) medium · light · desktop-web
The metric row controls the main chart: the selected tile is outlined and its series appears below. Supporting sections then break the results down by source, page, geography, and other categories. Ranked lists show counts and bars, but no visible share of the total. The map uses very pale colors for most countries, limiting how much variation is easy to see.