Resizable card grid 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. npx shadcn@latest add card button npm i lucide-react Tokens this needs: --card, --card-foreground, --muted-foreground, --border, --chart-1 ──────────────────────────────────────────────────────────────────────── // CardGrid.tsx ──────────────────────────────────────────────────────────────────────── 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(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, 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 (
{layout.map((p) => (
{/* A grip, never the whole header. */} {editing && ( )} {title(p.id)} {editing && ( )}
{render(p.id, { w: p.w, h: p.h })}
{editing && ( 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)}`} /> )}
))}
); } ──────────────────────────────────────────────────────────────────────── // demo.tsx ──────────────────────────────────────────────────────────────────────── 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 = { p95: "P95 by service" }; export default function Demo() { const [layout, setLayout] = useState(START); const [removed, setRemoved] = useState([]); 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 (
TITLES[id]} render={(_, size) => (
)} />
{removed.length > 0 && }
); }