Skip to content
KONIGI Patterns

Dashboards / Charts and data displays / Stacked composition

‹ 18 of 22 ›

Stacked composition

People need to see how individual parts contribute to a changing total.

Updated September 10, 2026

Problem

People need to see a total and how its parts contribute to changes in that total.

Solution

Stack categories using a common measure, keeping their order and colors stable. Label the total and make individual values available.

Use ordinary stacking to show both total quantity and composition. Use 100% stacking only when the question is about shares, and show the total separately if it still matters.

Place the category needing the closest comparison against the baseline. Use another chart when precise comparison among all categories is the main task.

Use when

The categories add up to a meaningful whole and both the total and its composition matter.

Don’t use when

The viewer needs to compare middle series against each other, or the categories don’t actually sum—stacking overlapping or double-counted quantities produces a total that means nothing. Above roughly six or seven categories, bands become slivers and the chart is a colour test.

Trade-offs

Middle bands are harder to compare because their baselines move. Changes below a band can make it appear to rise even when its own value is stable. A 100% chart hides changes in total volume.

Checklist

  • Do these parts genuinely sum to the whole, with no overlap or double counting?
  • Which series is at the bottom, and is it the one that most needs accurate reading?
  • Is category order stable between refreshes and between viewers?
  • How many categories, and is the thinnest band still visible?
  • Is there an “other” bucket, and does it carry a count?
  • Normal or 100%, and does the choice match the question being asked?
  • If 100%, is the total available anywhere on the page?
  • Do colours stay assigned to the same category across every panel?
  • Could a viewer mistake a rising baseline for a growing band?
  • Would small multiples let them compare the middle series properly?

Compare

Grafana offers stacking as Off, Normal and 100% across its time series, bar chart and histogram panels, which keeps the vocabulary consistent and makes the 100% mode easy to reach without its caveat attached. Datadog defaults many of its resource breakdowns to stacked areas, which suits infrastructure spend where the parts genuinely conserve and totals matter. Netdata stacks per-dimension charts heavily by default, which works because the dimensions are usually a true decomposition of a bounded resource. Amplitude leans on 100% stacking for composition over time, which is the honest use of that mode and still routinely gets screenshotted without the total anywhere in frame. Cloudflare Radar defuses the 100% caveat by printing every segment’s figure above the bar, so the chart can lose the total without the reader losing it. The bar ends up nearly redundant, which is a fair trade when the numbers were the point and the picture was only ever the index to them.

Time series provides the time axis for stacked areas. Ranked list makes dominant parts easier to compare. Ratio and rate explains percentage shares.

Categorical series palette distinguishes categories. Small multiples gives each part its own baseline.

Stacked composition anatomy A stacked area chart where the bottom band and the outer outline sit on steady edges and are readable, while the bands between them sit on a moving baseline and are only approximate. Below, the same data stacked normally and stacked to 100 percent, where a falling total reads as stable. What stacking can and can't tell you 1 2 3 1 THE MIDDLE BANDS Their bottom edge moves, and people judge the thickness of a band with a moving baseline badly. These are approximate. 2 THE BOTTOM BAND Flat baseline, so its shape is exactly readable. Put the series you most want to scrutinise here, on purpose. 3 THE OUTLINE Also exact. It is the total, and it is the other question the chart is answering. Normal, then 100% Same three series. The total is falling by a third. Normal · the total is visible 100% · the total is gone
Wireframe — the pattern's anatomy, not any one product's version of it

Implementation

Only the bottom band has a straight baseline, so only the bottom band is comparable across columns. Every band above it is being read off a moving floor, which is why a stack answers share and not trend.

shadcn
npx shadcn@latest add toggle-group
npm
recharts
Tokens
--card--muted--muted-foreground--border--chart-1--chart-2
MarAprMayJunJulAugSep
  • egress
  • storage
  • compute

StackedArea.tsxSeries order is the stacking order, bottom first. Mode is normal or 100%, and 100% prints the total it would otherwise hide.

import { Area, AreaChart, Tooltip, XAxis } from "recharts";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";

/**
 * Stacking is not symmetric. The bottom band sits on a flat baseline and is
 * exactly readable; the outline is the total and is exact too; every band
 * between them is read off a moving floor and is approximate. So the order
 * of `series` is a decision: the first one is the bottom, and it should be
 * the series that most needs scrutiny, never whatever the query returned.
 *
 * `mode` is a closed pair because the two answer different questions. Normal
 * answers "how much and of what". 100% answers "what share" and discards the
 * total, so in that mode the total is printed beneath the chart instead.
 */
export type Mode = "normal" | "percent";

export type Series = {
  key: string;
  label: string;
  values: number[];
  /** Assigned by key by the caller, so the same series is the same colour on every panel. */
  color: string;
};

const TICK = { fontSize: 10, fill: "hsl(var(--muted-foreground))" };

export function StackedArea({ labels, series, mode, unit, onModeChange, width = 400, height = 190 }: {
  labels: string[];
  /** Bottom first. */
  series: Series[];
  mode: Mode;
  unit: string;
  onModeChange?: (mode: Mode) => void;
  width?: number;
  height?: number;
}) {
  const data = labels.map((label, i) => Object.fromEntries([["label", label], ...series.map((s) => [s.key, s.values[i]])]));
  const totals = labels.map((_, i) => series.reduce((n, s) => n + s.values[i], 0));
  const first = totals[0], last = totals[totals.length - 1];
  const change = Math.round(((last - first) / first) * 100);

  return (
    <div>
      <div className="overflow-x-auto">
        <AreaChart width={width} height={height} data={data} stackOffset={mode === "percent" ? "expand" : "none"} margin={{ top: 8, right: 8, bottom: 0, left: 8 }}>
          <XAxis interval={0} dataKey="label" tick={TICK} stroke="hsl(var(--border))" tickLine={false} />
          <Tooltip formatter={(v, name) => [`${v} ${unit}`, series.find((s) => s.key === name)?.label ?? name]} />
          {series.map((s, i) => (
            <Area
              key={s.key}
              type="linear"
              dataKey={s.key}
              stackId="stack"
              fill={s.color}
              fillOpacity={0.6}
              // The outline is the total, and it is the other exact edge.
              stroke={mode === "normal" && i === series.length - 1 ? s.color : "none"}
              strokeWidth={2}
              isAnimationActive={false}
            />
          ))}
        </AreaChart>
      </div>

      <div className="mt-2 flex flex-wrap items-center gap-3 text-[11px] text-muted-foreground">
        <ul className="flex gap-3">
          {series.map((s) => (
            <li key={s.key} className="flex items-center gap-1.5">
              <span className="size-2 rounded-[2px]" style={{ background: s.color }} aria-hidden="true" />
              {s.label}
            </li>
          ))}
        </ul>
        {/* 100% hides the total so completely that a halving looks like nothing happened. Print it. */}
        {mode === "percent" && (
          <p className="tabular-nums">
            total {first} → {last} {unit}, {change === 0 ? "unchanged" : `${change > 0 ? "up" : "down"} ${Math.abs(change)}%`}
          </p>
        )}
        {onModeChange && (
          <ToggleGroup type="single" value={mode} onValueChange={(m) => m && onModeChange(m as Mode)} className="ml-auto gap-1" aria-label="stacking">
            <ToggleGroupItem value="normal" size="sm" className="h-6 rounded-full border px-2 text-[10px] data-[state=on]:bg-muted">Normal</ToggleGroupItem>
            <ToggleGroupItem value="percent" size="sm" className="h-6 rounded-full border px-2 text-[10px] data-[state=on]:bg-muted">100%</ToggleGroupItem>
          </ToggleGroup>
        )}
      </div>
    </div>
  );
}

demo.tsxHow it is called: three spend lines with the growing one at the bottom, and a toggle between normal and 100%.

import { useState } from "react";
import { StackedArea, type Mode } from "./StackedArea";

/**
 * Cloud spend by line over seven months. Egress is the smallest series and
 * the one growing, so it goes at the bottom where its shape can be read.
 * Switching to 100% keeps the total in a line beneath the chart.
 */
export default function Demo() {
  const [mode, setMode] = useState<Mode>("normal");
  return (
    <div className="w-fit rounded-lg border bg-card p-4">
      <StackedArea
        labels={["Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep"]}
        series={[
          { key: "egress", label: "egress", values: [4, 8, 12, 10, 16, 14, 18], color: "hsl(var(--muted-foreground))" },
          { key: "storage", label: "storage", values: [56, 44, 56, 32, 50, 32, 40], color: "hsl(var(--chart-2))" },
          { key: "compute", label: "compute", values: [54, 48, 56, 44, 54, 46, 50], color: "hsl(var(--chart-1))" },
        ]}
        mode={mode}
        unit="k$"
        onModeChange={setMode}
      />
    </div>
  );
}
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 2 more examples Hide the rest

Grafana

Examples of panel grids, shared selectors, stat tiles, and chart configuration.

Examples / Logs Panel September 10, 2026 Grafana Play (signed out; no version string exposed) medium · dark · desktop-web
A stacked volume chart sits above the individual log events, linking the overall pattern to specific lines. Each row shows a local timestamp and a UTC timestamp, but their timezones are not clearly labeled. That can make the same instant look like two different times. Expanded event details include a trace ID, providing a route to the request behind the message.
  • Stacked composition Stacked log counts show volume by level over time.
  • Log tail Log rows are truncated and contain timestamps in two timezones.
  • Drill-down The trace ID connects the log event to its request.

Kibana

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

Dashboards / [Flights] Global Flight Dashboard September 10, 2026 Elastic demo environment, sample flight data (guest session) dense · light · desktop-web
The delay table shows percentages without the number of flights behind each one. A high rate based on very few flights would look as prominent as one based on many. A nearby comparison tile names the previous week but does not clearly explain what its percentage represents. The filtering controls are more explicit: a query bar sits above pickers and a price range, giving people both typed and guided ways to narrow the view.
  • Ratio and rate Delay percentages appear without their flight counts.
  • Compare periods The comparison period is named, but the meaning of the percentage is unclear.
  • Share and embed Sharing and full-screen controls are available in the header.
  • Filter bar Pickers and a range control provide alternatives to typing a query.
  • Panel grid Panels use different sizes within a grid.
  • Stacked composition A 100% stack shows the mix of delay types without total volume.
  • Annotation Grouped event markers share the chart’s time axis.
  • Header KPI strip Different tile sizes and arrangements make the summary row less consistent.