Dashboard Grid
A draggable, resizable widget grid: widgets snap to a column grid, collisions push neighbours down deterministically, and every accepted change emits the whole layout for the consumer to persist.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/dashboard-grid.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "DashboardGrid" component with dnd-kit
(@dnd-kit/core), lucide-react and zod. No layout library: the packing engine is
~60 lines of pure functions.
Contract
- A zod schema file is the single source of truth, and the props are z.infer of
it plus the behaviour props below — never a parallel hand-written interface.
Widget: { id, title, x, y, w, h, minW?, minH?, pinned?, accent?: 1..5,
metric?: { value, caption?, delta?: { label, direction: "up"|"down"|"flat" } },
series?: number[], footnote? }. x/y/w/h are GRID CELLS, never pixels: x is the
0-based column of the left edge, w the column span.
- A Status union — "loading" | "empty" | "error" | "ready" — is the grid's own
render state, a sibling of items rather than a flag hidden inside them.
- id is unique across the whole grid: it is the React key, the drag id and the
key of the emitted layout entry. metric.value is ALREADY FORMATTED ("$48.2k"),
so the component never calls Intl.* or new Date() and server and client markup
match byte for byte.
- Props: items, status, columns = 12, rowHeight = 64 (px), gap = 16 (px),
stackBelow = 560 (px), onLayoutChange?(layout, { id, kind }), onRetry?,
renderWidget?(item), emptyState?, skeletonCount = 4, className. Forward the
ref to the root of every branch and spread the remaining native div props.
- Fully controlled: the component NEVER mutates items. An accepted move or
resize calls onLayoutChange exactly once with the WHOLE resolved layout —
{ id, x, y, w, h }[] in reading order — plus which widget changed and how.
If the consumer ignores it the widget springs back to where the props say it
is; that is the contract, not a bug.
Behavior
- Four first-class branches on status: loading → widget-shaped skeletons laid
out with the same geometry (aria-busy, motion-reduce:animate-none); empty → an
icon + copy slot replaceable through emptyState; error → a message plus a
"Try again" button rendered only when onRetry is passed; ready → the live grid.
Every root a retry can hand over to (loading / empty / ready) is a
tabIndex={-1} focus target, because "Try again" unmounts the button that held
focus and the browser would drop it on <body>: a one-shot ref set in the click
handler makes the next branch take focus, and only when focus really did land
on <body> — never pull it back from wherever the user moved on to.
- The layout engine is pure, deterministic and DOM-free, in three passes:
* normalize — round every number, clamp w into [minW, columns], x into
[0, columns - w], h to at least minH, y to at least 0, and drop duplicate
ids (a duplicate would collide as a React key AND make one drag move two
widgets).
* pack (push-down) — place pinned widgets first, then the anchor (the widget
the user is placing right now, which keeps the exact cell it was dropped
on), then the rest in reading order: y, then x, id breaking exact ties.
Each widget slides down while it overlaps something already placed:
y = hit.y + hit.h. y only ever grows and every step clears one widget for
good, so it settles in at most n steps and the output has zero overlaps.
* gravity — in reading order every widget floats up while the row above it is
free. Pinned widgets keep their row, and nothing floats through them.
- Resolving one user move is TWO settles, and the order is the whole trick:
first settle everything EXCEPT the anchor, so the board closes the hole the
anchor left behind; then drop the anchor onto its requested cell, let the rest
flow around it and apply gravity to all of them. Skip the first pass and
gravity drags the anchor straight back into the gap it just vacated, which
means a widget could never be moved DOWN past its neighbour.
- Gravity still has the last word on which rows exist: a widget can only rest
where something supports it from above, so a request that would leave a hole
above the anchor floats it straight back onto its own cell. Treat that as a
refusal, not as a result — throw the whole resolve away and keep the snapshot,
because the neighbours it displaced on the way past do not always float back
(a pinned widget can hold one down), and committing that would rewrite the
board for a move the user watched do nothing. A refused gesture changes
nothing, emits nothing and announces nothing.
- Every step of an interaction re-resolves from the snapshot taken when the
interaction began, never from the previous frame, so pushing a neighbour is
fully reversible and the preview is exactly what gets committed.
- Pointer: one DndContext, a PointerSensor with a 4px activation distance, and
TWO useDraggable per widget — a grip in the header (kind "move") and a corner
handle (kind "resize"). Their ids are prefixed ("dashboard-widget:",
"dashboard-resize:") so a widget id can never collide with a handle id, and
each carries data { kind, itemId } so a drag resolves without parsing ids.
There are no droppables at all: the target cell comes from the drag delta.
- Delta to cells: dx = round(delta.x / (colWidth + gap)) and
dy = round(delta.y / (rowHeight + gap)), where colWidth = (trackWidth -
(columns - 1)·gap) / columns is measured once at drag start. A move adds
(dx, dy) to the origin cell, a resize adds them to w and h and then clamps w
into [minW, columns - x]. An unmeasured track (width 0) must yield 0 cells,
not NaN.
- While a move drag is live the widget follows the cursor with a translate3d and
no transition, and a dashed placeholder marks the resolved target cell. A
resize instead snaps cell by cell, so what you see during the drag is exactly
what commits.
- Keyboard is a first-class path and it is NOT dnd-kit's KeyboardSensor (that
one speaks pixels, this grid speaks cells): Enter or Space on the grip picks
the widget up, the arrow keys move it one cell, Enter or Space drops it,
Escape restores the snapshot. The same keys on the corner handle resize it
(left/right change w, up/down change h). preventDefault on Space (page
scroll), Enter (form submit) and the arrows (scrolling); ignore auto-repeat on
the confirm key so holding it cannot toggle the grab twice a frame; cancel the
grab on blur, because a grab nobody can steer is a trap. The request snaps
onto where the widget actually landed whenever it moved, so a step is never
banked up invisibly; while it is refused the request keeps the row the user
has stepped to, because the next supported row can be several cells away and
re-asking for the same refused row for ever is what makes a keyboard move
downwards impossible.
- Emit exactly once: the confirm handler reads AND clears the interaction ref
synchronously before it does anything else (dragMove and dragEnd land in the
same tick, where a state value is still the previous one), and a gesture whose
serialized layout equals the snapshot's emits nothing at all.
- ARIA: the root is role="group" with a label; each widget is a <section
aria-labelledby> with an <h3> title; the grip is a real <button
aria-label="Move <title>"> carrying aria-pressed while it is grabbed, the
corner a <button aria-label="Resize <title>">. Silence dnd-kit's own
announcements and route every message — pointer and keyboard — through the one
polite role="status" region the component owns: pick-up, each step ("column 4,
row 2" / "3 columns by 2 rows"), the drop, the cancel and the refusals. Pass
id={useId()} to DndContext or its internal aria ids drift between the server
and client render.
- Narrow containers: a single ResizeObserver on the root; below stackBelow the
grid degrades to a read-only vertical stack in reading order, because dragging
a 12-column layout on a phone only ever scrambles it. The handles stay mounted
and focusable with aria-disabled — never the native disabled attribute, which
blurs the node onto <body> the instant it is set — and the key handler
announces the refusal instead of silently doing nothing. Disconnect the
observer on unmount and whenever the status branch swaps the root element.
- Degenerate data is the normal case for anything that was persisted:
overlapping rectangles get separated, out-of-range spans clamped, a y of 40
pulled up by gravity, a duplicate id dropped, a 1-sample or zero-variance
sparkline drawn as a flat mid line, and non-finite samples filtered out.
Rendering & styling
- Semantic tokens only: bg-card + border for a widget, bg-muted /
text-muted-foreground for chips, skeleton bars and captions, ring-ring for
focus and for the lifted widget, border-primary/50 + bg-primary/5 for the drop
placeholder, text-destructive for the error state and a falling delta,
text-primary for a rising one, and var(--chart-1..5) for the sparkline stroke
selected by accent. cn() merges the consumer className into the root of every
branch.
- Geometry without measuring: with C columns and gap G,
left = calc(x/C·100% + G·x/C px) and width = calc(w/C·100% − G·(C−w)/C px),
while rows are a fixed height so top = y·(rowHeight+G) and
height = h·rowHeight + (h−1)·G. The first paint (and SSR) is already correct
instead of snapping into place one frame later, and only the stack breakpoint
needs a measured width. The root therefore carries no padding of its own —
put padding on a wrapper.
- The root gets an explicit height of rows·(rowHeight+gap) − gap, so absolutely
positioned widgets still push the page down. Widgets transition
left/top/width/height over 150ms with motion-reduce:transition-none, and the
widget under the cursor drops the transition entirely — dragging, resizing and
the keyboard path all keep working with motion off.
- Sparkline: an inline <svg viewBox="0 0 100 100" preserveAspectRatio="none">
polyline with vectorEffect="non-scaling-stroke", so the stroke stays even when
the box is stretched; aria-hidden, because the number beside it is the
accessible value.
- Direction never rides on colour alone: a lucide TrendingUp / TrendingDown /
Minus carries it and the token only reinforces it.
Customization levers
- Density: columns / rowHeight / gap are the entire layout axis — 12 × 64 × 16
reads as a roomy dashboard, 6 × 48 × 12 as a compact panel. Nothing else has
to change, because every cell is derived from those three numbers.
- stackBelow is the responsive lever: raise it to hand tablets the read-only
stack as well, or pass 0 to keep the drag grid at every width.
- Widget anatomy: renderWidget(item) replaces the body while the grid keeps the
shell, the header, both handles and all drag state — use it for charts,
tables or a live map. Or keep the default body and drop the blocks you do not
need (metric / delta / sparkline / footnote).
- Gravity: delete the final gravity pass for free placement, where widgets stay
exactly where they were dropped and holes are legal; the push-down pass alone
still guarantees no overlaps.
- Delta tone: up = text-primary and down = text-destructive is a judgement, not
a fact. Swap the two token strings for metrics where falling is good — churn,
latency, cost per request.
- Pinning: pinned is per widget and optional. Use it for a masthead widget the
rest of the board has to flow around; leave it off for a fully free board.
- Persistence: onLayoutChange hands you exactly the shape to store,
{ id, x, y, w, h }[]. Debounce it if you write straight to a server, and feed
the stored array back in as items on the next mount.Concepts
- Two-pass settle — the board first closes the hole the dragged widget left, and only then does the widget land; that ordering is what lets a widget be dragged downwards past a neighbour under vertical gravity instead of being sucked straight back to the top.
- Supported rows only — gravity means a widget can rest only where something holds it up, so a row with a hole above it is not a landing place: that request is refused whole. The widget keeps its cell, the neighbours it displaced on the way past are put back, and nothing is emitted or announced — while the keyboard keeps stepping the request down until it reaches a row that is supported.
- Push down, then float up — collisions only ever displace a neighbour downwards, and gravity pulls the whole board back up in reading order, so the same two rules produce a stable layout no matter which widget moved.
- Snapshot-relative steps — every pointer move and every arrow press is resolved against the layout as it was when the gesture started, never against the previous frame, which makes a push reversible and the preview identical to the committed result.
- Controlled layout — the grid resolves where things belong and reports it once; storing it is the consumer's job, which is what lets the same component drive local state, a debounced PATCH or an optimistic mutation.
- Keyboard grab mode — Enter picks up, arrows step one cell, Enter drops, Escape restores: the identical resolve pipeline as the pointer path, narrated in one live region, so the feature is not pointer-only.
- Degenerate persistence — saved layouts rot: rectangles overlap, spans outgrow the grid, ids duplicate. Normalising them on the way in is the difference between a stale row in a database and a broken page.
Timeline Swimlane
Events across parallel resource lanes on one shared time axis, with overlap stacking, hour/day/week zoom, a caller-supplied now marker and four data states.
Column Picker
A table column manager: show/hide toggles, drag or keyboard reorder, pinned columns that refuse to hide with a reason, search and reset — emitting the ordered visible set.