Panel grid 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. npx shadcn@latest add card Tokens this needs: --card, --muted, --muted-foreground, --border, --chart-1 ──────────────────────────────────────────────────────────────────────── // PanelGrid.tsx ──────────────────────────────────────────────────────────────────────── 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 (
{guides && ( )} {/* 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. */}
{panels.map((p) => { const on = p.id === selected; return ( 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", )} >
{p.title} {on && ( e.stopPropagation()}> {p.w} × {p.h} )}
{p.children}
); })}
); } ──────────────────────────────────────────────────────────────────────── // demo.tsx ──────────────────────────────────────────────────────────────────────── 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: ( ), }, { 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 ; }