Skip to content
KONIGI Patterns

Dashboards / Page layout / Resizable card grid

‹ 3 of 5 ›

Resizable card grid

People need to arrange shared dashboard panels to suit their own work.

Updated September 10, 2026

Problem

People doing different jobs may need the same panels in different sizes or positions.

Solution

Let people move and resize panels in a clearly marked edit mode. Save personal arrangements separately from the shared default.

Provide a useful starting layout, an undo action, and a way to restore the default. Make layout changes possible without relying only on dragging.

Check saved arrangements at different screen sizes. Explain when a saved panel has been removed or changed so an old layout does not silently become incomplete.

Use when

People need different arrangements of a stable panel set and do not depend on a shared spatial layout.

Don’t use when

The page is used collaboratively, especially in incidents. Also don’t use it as a way to avoid deciding what the page is for; customisation is not a substitute for editorial judgement, and it is frequently deployed as one.

Trade-offs

Personal layouts make directions such as “top right” unreliable. They can become outdated as the system changes. Customization also adds storage and maintenance work and should not replace designing a good default.

Checklist

  • Does anyone need to talk about this page with someone else looking at it?
  • What proportion of viewers actually customise, and how do you know?
  • Is the default layout still receiving design attention?
  • Does a customised layout survive a change to the panel set?
  • Is a viewer prompted when their saved layout references something removed?
  • Can a viewer reset to the default in one action?
  • Does layout editing require an explicit edit mode, or can it happen by accident?
  • Is there an undo?
  • Does a layout made on a wide screen work on a laptop?
  • Would saved views or separate dashboards serve these audiences better?

Compare

Grafana makes every dashboard editable by anyone with permission, so the arrangement is shared rather than personal and edits are changes to a common artefact, which trades personalisation for a shared vocabulary. Datadog offers both editable dashboards and personal starred views, keeping the shared page canonical while letting individuals collect what they need elsewhere. Kibana leans furthest into free arrangement of cards, each carrying its own query, which is flexible and puts all coherence on the author. Public status pages are the deliberate opposite, with no customisation at all, because a page read by strangers must look the same to everyone.

Panel grid supplies the layout structure. Saved view preserves useful settings. Dashboard builder supports broader authoring tasks.

Semantic grouping helps create a sensible default. Multi-page dashboard may fit audiences that need different content, not just different positions.

Resizable card grid anatomy A card with a drag handle, a remove control and a resize grip, sitting on the snap grid it moves against. Below, two people's saved arrangements of the same five cards, where the panel in the top right is a different panel for each of them. Anatomy of one card P95 by service 1 2 3 4 1 DRAG HANDLE A grip, not the whole header. A card you move by grabbing anywhere moves when somebody meant to select a label. 2 REMOVE Needs an undo. Nothing else on the page deletes on a single click. 3 RESIZE GRIP Resizing changes what the panel can show, so the chart inside has to have an opinion about being small. 4 THE SNAP GRID Free positioning produces pages nobody can align. Columns do the tidying. "It's the panel in the top right" Priya's layout Sam's layout
Wireframe — the pattern's anatomy, not any one product's version of it

Implementation

Readers arranging their own panels. Edit mode has to be a mode, visibly, because a grid where a stray drag silently reorders someone's dashboard trains people not to touch it.

shadcn
npx shadcn@latest add card button
npm
lucide-react
Tokens
--card--card-foreground--muted-foreground--border--chart-1
P95 by service

CardGrid.tsxA CSS grid with a snap, a grip to move by, a corner to resize by, and an edit mode that is a required prop. Remove is delegated so the caller can undo it.

import { useRef, type PointerEvent, type ReactNode } from "react";
import { GripVertical, X } from "lucide-react";
import { Card } from "@/components/ui/card";

/** Where a card sits, in grid units. Free pixels produce pages nobody can
 *  align; the columns do the tidying. */
export type Placement = { id: string; x: number; y: number; w: number; h: number };

type Props = {
  layout: Placement[];
  columns: number;
  rowHeight: number;
  /** Editing is a mode, and it is required. A grid that reorders on a stray
   *  drag trains people not to touch it. */
  editing: boolean;
  onChange: (layout: Placement[]) => void;
  /** Nothing else on the page deletes on one click, so the caller owns the undo. */
  onRemove: (id: string) => void;
  title: (id: string) => string;
  render: (id: string, size: { w: number; h: number }) => ReactNode;
};

export function CardGrid({ layout, columns, rowHeight, editing, onChange, onRemove, title, render }: Props) {
  const ref = useRef<HTMLDivElement>(null);

  /** One handler for move and resize: both are "this many cells from where
   *  the pointer went down", snapped, clamped to the grid. */
  const start = (e: PointerEvent<HTMLElement>, id: string, mode: "move" | "size") => {
    if (!editing || !ref.current) return;
    e.preventDefault();
    const colWidth = ref.current.clientWidth / columns;
    const from = layout.find((p) => p.id === id)!;
    const x0 = e.clientX, y0 = e.clientY;
    const el = e.currentTarget;
    el.setPointerCapture(e.pointerId);
    const move = (ev: globalThis.PointerEvent) => {
      const dx = Math.round((ev.clientX - x0) / colWidth);
      const dy = Math.round((ev.clientY - y0) / rowHeight);
      const next = mode === "move"
        ? { ...from, x: Math.min(Math.max(0, from.x + dx), columns - from.w), y: Math.max(0, from.y + dy) }
        : { ...from, w: Math.min(Math.max(1, from.w + dx), columns - from.x), h: Math.max(1, from.h + dy) };
      onChange(layout.map((p) => (p.id === id ? next : p)));
    };
    const up = () => { el.removeEventListener("pointermove", move); el.removeEventListener("pointerup", up); };
    el.addEventListener("pointermove", move);
    el.addEventListener("pointerup", up);
  };

  const rows = Math.max(3, ...layout.map((p) => p.y + p.h));
  return (
    <div
      ref={ref}
      className="relative grid gap-0"
      style={{
        gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))`,
        gridAutoRows: `${rowHeight}px`,
        minHeight: rows * rowHeight,
        // The snap grid, drawn only while it can be used.
        backgroundImage: editing
          ? `repeating-linear-gradient(to right, hsl(var(--border)) 0 1px, transparent 1px ${100 / columns}%), repeating-linear-gradient(to bottom, hsl(var(--border)) 0 1px, transparent 1px ${rowHeight}px)`
          : undefined,
      }}
    >
      {layout.map((p) => (
        <Card
          key={p.id}
          className="relative m-1 flex flex-col p-3"
          style={{ gridColumn: `${p.x + 1} / span ${p.w}`, gridRow: `${p.y + 1} / span ${p.h}` }}
        >
          <div className="flex items-center gap-2">
            {/* A grip, never the whole header. */}
            {editing && (
              <button type="button" onPointerDown={(e) => start(e, p.id, "move")} className="cursor-grab touch-none text-muted-foreground" aria-label={`move ${title(p.id)}`}>
                <GripVertical className="size-3.5" />
              </button>
            )}
            <span className="text-[11px] text-card-foreground">{title(p.id)}</span>
            {editing && (
              <button type="button" onClick={() => onRemove(p.id)} className="ml-auto text-muted-foreground" aria-label={`remove ${title(p.id)}`}>
                <X className="size-3.5" />
              </button>
            )}
          </div>
          <div className="mt-2 min-h-0 flex-1">{render(p.id, { w: p.w, h: p.h })}</div>
          {editing && (
            <span
              onPointerDown={(e) => start(e, p.id, "size")}
              className="absolute bottom-1 right-1 size-2 cursor-nwse-resize touch-none border-b-2 border-r-2 border-muted-foreground"
              role="separator" aria-label={`resize ${title(p.id)}`}
            />
          )}
        </Card>
      ))}
    </div>
  );
}

demo.tsxHow it is called: one card on four columns, editing on, with an undo stack for remove.

import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Sparkline } from "../sparkline/Sparkline";
import { CardGrid, type Placement } from "./CardGrid";

/**
 * One card on a four-column grid, in edit mode. Drag the grip to move it a
 * cell at a time, the corner to resize. Remove keeps the card in a stack so
 * the same button puts it back.
 */
const START: Placement[] = [{ id: "p95", x: 0, y: 0, w: 3, h: 2 }];
const TITLES: Record<string, string> = { p95: "P95 by service" };

export default function Demo() {
  const [layout, setLayout] = useState(START);
  const [removed, setRemoved] = useState<Placement[]>([]);
  const [editing, setEditing] = useState(true);

  const remove = (id: string) => {
    const card = layout.find((p) => p.id === id);
    if (!card) return;
    setRemoved([...removed, card]);
    setLayout(layout.filter((p) => p.id !== id));
  };
  const undo = () => {
    const last = removed[removed.length - 1];
    if (!last) return;
    setRemoved(removed.slice(0, -1));
    setLayout([...layout, last]);
  };

  return (
    <div className="w-[320px]">
      <CardGrid
        layout={layout}
        columns={4}
        rowHeight={60}
        editing={editing}
        onChange={setLayout}
        onRemove={remove}
        title={(id) => TITLES[id]}
        render={(_, size) => (
          <div className="text-chart-1">
            <Sparkline values={[28, 38, 30, 50, 44, 60, 56]} width={size.w * 80 - 32} height={size.h * 60 - 60} />
          </div>
        )}
      />
      <div className="mt-3 flex gap-2">
        <Button variant="outline" size="sm" onClick={() => setEditing(!editing)}>{editing ? "done" : "edit layout"}</Button>
        {removed.length > 0 && <Button variant="ghost" size="sm" onClick={undo}>undo remove</Button>}
      </div>
    </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.

Home Assistant

Room-based card layouts combine household device states with controls.

Demo dashboard at phone width September 10, 2026 Home Assistant public demo (signed out, 390px viewport) medium · light · mobile
The desktop dashboard reflows into a narrow phone layout while keeping its room sections and controls. Cards remain in two columns within sections, limiting the page’s length. The section order differs from the desktop columns, so the responsive layout changes what people encounter first. Navigation moves behind a menu button, while the summary chips remain at the top.
Show 1 more example Hide the rest

Home Assistant

Room-based card layouts combine household device states with controls.

Demo dashboard September 10, 2026 Home Assistant public demo (signed out) medium · light · desktop-web
Cards are grouped by rooms such as Living room, Kitchen, and Study. States are written in words, with icon color providing a second cue. Section headers add useful room summaries, such as temperature or meeting status. The page also contains controls, including lighting sliders and thermostat buttons, so people can act in the same place they check the state.