Display

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import {
  DndContext,
  PointerSensor,
  useDraggable,
  useSensor,
  useSensors,
  type Announcements,
  type DragEndEvent,
  type DragMoveEvent,
  type DragStartEvent,
  type ScreenReaderInstructions,

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/dashboard-grid.json

Prompt

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.

On This Page