Skip to content
KONIGI Patterns

Dashboards / Managing screen space / Panel grid

‹ 5 of 6 ›

Panel grid

People need a clear arrangement of panels with enough space to read each one.

Updated September 10, 2026

Problem

Charts, tables, and summary values need different amounts of space. Without a clear layout, people struggle to see where to start and which panels belong together.

Solution

Arrange panels on a shared grid with consistent spacing and alignment. Allow different panel sizes where the content or priority calls for them.

Place the main answer early in the reading order and group related panels. Give charts enough width and tables enough height to remain useful.

Define the order for smaller screens explicitly. Check layouts at the widths people use instead of assuming a desktop arrangement will reflow sensibly.

Use when

A page contains several panels that people need to scan and compare.

Don’t use when

The page has a reading order. A narrative that walks someone through an argument wants a single column, because a grid invites the eye to jump and a narrative depends on it not doing that.

Trade-offs

A rigid grid can force content into unsuitable shapes. Equal-sized panels can imply equal importance. Custom layouts may work on one screen and become confusing or unreadable on another.

Checklist

  • How many columns, and does the count support the fractions this page needs?
  • Does the top-left panel deserve to be the first thing read?
  • Does panel size encode importance, or is everything the same size by default?
  • Do panels on a row belong together, or did they land there by packing?
  • What order do panels take when the grid collapses to one column?
  • What is the shortest viewport this page is used at, and what falls below the fold there?
  • Is there a minimum size below which a given panel type stops being readable?
  • Do gaps and alignment survive panels with different title lengths?
  • Can an author create a layout that only works at their window size?
  • Is the arrangement stable, or does it reflow differently for different viewers?

Compare

Grafana uses a 24-unit horizontal grid with drag-and-drop sizing and a packing behaviour that pulls panels upward into gaps, which makes editing fast and means moving one panel can rearrange several others. Datadog offers both a fixed grid and a free-form canvas, so a team can choose between consistency and expressive layout, and most eventually regret whichever they picked. Kibana treats panels as cards on a resizable grid with each panel carrying its own query, which pushes coherence entirely onto whoever assembles the page. Honeycomb barely has this pattern: the primary surface is a query and its result, so composition happens in boards that collect saved queries rather than in a canvas of independent tiles.

Collapsible row organizes longer grids. Dense small-multiple layout emphasizes comparison across many charts. Header KPI strip provides a top-level summary.

Resizable card grid gives viewers layout controls. Semantic grouping guides which panels sit together.

Panel grid anatomy A twelve-column grid with its columns ruled, panels snapping to spans of twelve, six, four and three, and one panel four times the size of its neighbours so the layout says where to start without a word of copy. Twelve columns, one row unit P95 checkout latency 6 columns 3 3 1 2 1 SIZE MEANS SOMETHING A panel four times its neighbours says "start here" without copy. If every panel is the same size, the viewer has to read all twenty titles. 2 WHY TWELVE It divides by 2, 3, 4 and 6, so halves, thirds, quarters and sixths all land on a column line without fractions. Top-left reads as the answer whether you meant it to or not. The only question is whether the panel that landed there deserves it.
Wireframe — the pattern's anatomy, not any one product's version of it

Implementation

A fixed column count with panels spanning it. Twelve columns is the convention because it divides by two, three and four, which covers nearly every row a dashboard needs.

shadcn
npx shadcn@latest add card
Tokens
--card--muted--muted-foreground--border--chart-1
P95 checkout latency6×2
Error rate
Orders per minute
Payment provider
Queue depth
Failing SKUs
Latency by region
Retries
Refunds
Carts abandoned

PanelGrid.tsxTwelve columns as a constant, a panel as a span of them, authored order kept, the selected panel resized a column or a row at a time.

import type { CSSProperties, ReactNode } from "react";
import { Card } from "@/components/ui/card";
import { cn } from "@/lib/utils";

/**
 * A coordinate system, not a layout. Twelve columns because twelve divides
 * by 2, 3, 4 and 6, so halves, thirds, quarters and sixths all land on a
 * column line. Panels say how many they span and the grid does the rest.
 */
export const COLUMNS = 12;

export type Panel = {
  id: string;
  title: string;
  /** Columns of twelve. */
  w: number;
  /** Row units. Size is allowed to mean something: a panel four times its
   *  neighbours says "start here" without copy. */
  h: number;
  children?: ReactNode;
};

const clamp = (n: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, n));

export function PanelGrid({ panels, onChange, selected, onSelect, rowHeight = 48, guides = false }: {
  panels: Panel[];
  onChange: (panels: Panel[]) => void;
  selected?: string;
  onSelect: (id: string) => void;
  rowHeight?: number;
  /** Rule the columns, as an editor does. */
  guides?: boolean;
}) {
  const resize = (id: string, dw: number, dh: number) =>
    onChange(panels.map((p) => (p.id === id ? { ...p, w: clamp(p.w + dw, 1, COLUMNS), h: clamp(p.h + dh, 1, 8) } : p)));

  return (
    <div className="relative">
      {guides && (
        <div aria-hidden="true" className="pointer-events-none absolute inset-0 grid grid-cols-12 max-sm:hidden">
          {Array.from({ length: COLUMNS }, (_, i) => (
            <span key={i} className={cn("border-l", i === COLUMNS - 1 && "border-r")} />
          ))}
        </div>
      )}

      {/* Authored order, no dense packing: a panel lands where its author put
          it, and the top-left is a decision rather than a side effect. */}
      <div className="relative grid grid-cols-12 gap-2 max-sm:grid-cols-1" style={{ gridAutoRows: rowHeight }}>
        {panels.map((p) => {
          const on = p.id === selected;
          return (
            <Card
              key={p.id}
              role="button"
              tabIndex={0}
              aria-pressed={on}
              onClick={() => onSelect(p.id)}
              onKeyDown={(e) => e.key === "Enter" && onSelect(p.id)}
              style={{ "--w": p.w, "--h": p.h } as CSSProperties}
              className={cn(
                "flex flex-col overflow-hidden p-3 [grid-column:span_var(--w)] [grid-row:span_var(--h)] max-sm:[grid-column:1/-1]",
                on ? "border-2 border-chart-1" : "bg-muted",
              )}
            >
              <div className="flex items-start justify-between gap-2 text-[10px]">
                <span className={on ? "text-chart-1" : "text-muted-foreground"}>{p.title}</span>
                {on && (
                  <span className="flex gap-1 tabular-nums text-muted-foreground" onClick={(e) => e.stopPropagation()}>
                    <button type="button" aria-label="narrower" onClick={() => resize(p.id, -1, 0)}>−</button>
                    {p.w}
                    <button type="button" aria-label="wider" onClick={() => resize(p.id, 1, 0)}>+</button>
                    <span className="mx-1">×</span>
                    <button type="button" aria-label="shorter" onClick={() => resize(p.id, 0, -1)}>−</button>
                    {p.h}
                    <button type="button" aria-label="taller" onClick={() => resize(p.id, 0, 1)}>+</button>
                  </span>
                )}
              </div>
              {p.children}
            </Card>
          );
        })}
      </div>
    </div>
  );
}

demo.tsxHow it is called: ten checkout panels, the latency chart at six by two, guides on.

import { useState } from "react";
import { PanelGrid, type Panel } from "./PanelGrid";

/**
 * One panel four times the size of its neighbours, top-left, so the page
 * says where to start. Selecting a panel shows its span and lets you change
 * it a column or a row at a time; the guides are on because this is the
 * layout being edited.
 */
const LATENCY = "M0 44 L52 32 L104 38 L156 14 L208 22 L260 2 L284 -4";

const START: Panel[] = [
  {
    id: "p95", title: "P95 checkout latency", w: 6, h: 2,
    children: (
      <svg viewBox="0 -6 290 58" className="mt-2 w-full" aria-hidden="true">
        <path d={LATENCY} fill="none" className="stroke-chart-1" strokeWidth="2" />
      </svg>
    ),
  },
  { id: "errors",    title: "Error rate",          w: 3, h: 1 },
  { id: "orders",    title: "Orders per minute",   w: 3, h: 1 },
  { id: "provider",  title: "Payment provider",    w: 3, h: 1 },
  { id: "queue",     title: "Queue depth",         w: 3, h: 1 },
  { id: "failing",   title: "Failing SKUs",        w: 3, h: 2 },
  { id: "regions",   title: "Latency by region",   w: 3, h: 2 },
  { id: "retries",   title: "Retries",             w: 2, h: 2 },
  { id: "refunds",   title: "Refunds",             w: 2, h: 2 },
  { id: "abandoned", title: "Carts abandoned",     w: 2, h: 2 },
];

export default function Demo() {
  const [panels, setPanels] = useState(START);
  const [selected, setSelected] = useState("p95");
  return <PanelGrid panels={panels} onChange={setPanels} selected={selected} onSelect={setSelected} guides />;
}
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.

Grafana

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

Where The Cool Dashboards Live / Chicago L Trains September 10, 2026 Grafana Play (signed out; no version string exposed) sparse · dark · wall
The departure board gives the next train more space than later arrivals and uses large text without hover-only details. Route colors connect the display to the train lines people already know. Green also appears in the On Time status, so the same hue has two meanings in one row. The status label helps distinguish them. Countdown values are useful, but the board also needs a clear indication of feed age.
  • Wallboard mode The next departure receives the largest text and panel.
  • Panel grid Later departures use smaller rows, making their lower priority visible.
  • Semantic status color Green identifies both a route and an on-time status; the words distinguish the meanings.
  • Freshness indicator A countdown shows time until arrival but does not state the feed’s age.
  • Data table The raw feed includes the color value for each route.
Show 1 more example Hide the rest

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.