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 && (
{Array.from({ length: COLUMNS }, (_, i) => (
))}
)}
{/* 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. */}