editable-dashboard
v4.3.0
Published
Headless React library for dashboard layouts with drag-and-drop, bin-packing, undo/redo, and serialization
Maintainers
Readme
editable-dashboard
A headless, zero-dependency React library for building fully customizable dashboard layouts with drag-and-drop reordering, dynamic resizing, and smart bin-packing.
Demo: https://nbotond20.github.io/editable-dashboard/
Features
- Headless architecture -- you bring your own UI components and animation library
- Bin-packing layout engine -- widgets flow into columns automatically, filling gaps efficiently
- 5 intelligent drag strategies -- insert, swap, side-drop, row squeeze, and column shift
- Controlled or uncontrolled -- manage state externally or let the provider handle it
- Serialization built in -- save and restore dashboard layouts with
serializeDashboard/deserializeDashboard - Pointer-based drag system -- works with mouse and touch; supports Escape to cancel
- Auto-measuring heights -- widgets are measured via
ResizeObserver; no fixed heights required - External drag-to-add -- drag widgets from a side panel or toolbar onto the dashboard using HTML5 Drag and Drop
- Headless trash zone -- opt-in drop zone for removing widgets or cancelling external adds during drag
- Double-click to maximize -- double-click a widget's drag handle to toggle full-width
- Empty-row maximize on dwell -- dragging a shrunk widget into an empty row and holding maximizes it to full width
- Configurable columns -- 1, 2, 3, or 4 column layouts with adjustable gaps
- Fully typed -- written in TypeScript with every type exported
- Widget locking -- lock position, resize, or removal at the definition or per-instance level
- Undo/redo -- built-in history with
Ctrl+Z/Ctrl+Ykeyboard shortcuts - Keyboard drag -- full keyboard navigation with arrow keys, Space, and Escape
- Tree-shakeable -- marked
sideEffects: false; import only what you use - Configurable drag behavior -- tune activation thresholds, dwell times, scroll speed, and more via
dragConfig - Lifecycle callbacks --
onDragStart,onDragEnd,onWidgetAdd,onWidgetRemove, and more - Error handling --
onErrorcallback with typed error codes for validation failures - Input validation -- definitions, props, and serialized data are validated with descriptive errors
Quick Start
Installation
npm install editable-dashboard
# or
yarn add editable-dashboard
# or
pnpm add editable-dashboardPeer dependencies: React 18+ (including React 19) and ReactDOM 18+.
Minimal Example
import {
DashboardProvider,
useDashboard,
type WidgetDefinition,
type WidgetState,
} from "editable-dashboard";
// 1. Define your widget types
const definitions: WidgetDefinition[] = [
{ type: "stats", label: "Statistics", defaultColSpan: 1 },
{ type: "chart", label: "Chart", defaultColSpan: 2 },
];
// 2. Seed initial widgets (optional)
const initialWidgets: WidgetState[] = [
{ id: "w1", type: "stats", colSpan: 1, visible: true, order: 0 },
{ id: "w2", type: "chart", colSpan: 2, visible: true, order: 1 },
];
// 3. Build your grid
function MyGrid() {
const {
state,
layout,
actions,
dragState,
containerRef,
measureRef,
startDrag,
getDragPosition,
} = useDashboard();
const visibleWidgets = state.widgets
.filter((w) => w.visible)
.sort((a, b) => a.order - b.order);
const activeLayout = dragState.previewLayout ?? layout;
return (
<div
ref={containerRef}
style={{
position: "relative",
height: activeLayout.totalHeight || "auto",
}}
>
{visibleWidgets.map((widget) => {
const pos = activeLayout.positions.get(widget.id);
if (!pos) return null;
return (
<div
key={widget.id}
ref={measureRef(widget.id)}
style={{
position: "absolute",
left: pos.x,
top: pos.y,
width: pos.width,
}}
>
{/* Drag handle */}
<button
onPointerDown={(e) => {
e.preventDefault();
startDrag(
widget.id,
e.pointerId,
{ x: e.clientX, y: e.clientY },
e.currentTarget as HTMLElement,
);
}}
style={{ cursor: "grab", touchAction: "none" }}
>
Drag
</button>
{/* Your widget content */}
<div>Widget: {widget.type}</div>
</div>
);
})}
</div>
);
}
// 4. Wrap in the provider
export default function App() {
return (
<DashboardProvider
definitions={definitions}
initialWidgets={initialWidgets}
maxColumns={2}
gap={16}
>
<MyGrid />
</DashboardProvider>
);
}Core Concepts
Widget Definitions vs Widget State
| Concept | Purpose | Mutable at runtime? |
| ---------------------- | -------------------------------------------------------------------------------------------- | ---------------------------------------------------------- |
| WidgetDefinition | Describes a type of widget (label, default span, constraints). Passed to the provider. | Replace the whole definitions array. |
| WidgetState | Represents an instance of a widget on the dashboard (id, current span, visibility, order). | Yes -- via actions (resizeWidget, removeWidget, etc.). |
Definitions are a catalog; state is the live layout.
The Headless Approach
The library provides state, layout coordinates, and drag behavior -- but zero rendered DOM beyond the context provider. You build your grid container, your widget wrappers, your drag handles, and your animations. This means you can use Framer Motion, CSS transitions, React Spring, or nothing at all.
Layout Engine
The layout engine uses a first-fit bin-packing algorithm:
- Widgets are sorted by their
order(hidden widgets are skipped). - For each widget, the algorithm scans all possible column start positions and picks the one with the lowest Y value (the "highest" available slot).
- If a widget has a
columnStarthint, that column is preferred instead. - Widget widths are computed from their
colSpan, thecontainerWidth, and thegap. - Heights come from real DOM measurements via
ResizeObserver(falling back to 200px).
Drag-and-Drop System
Drag uses a zone-to-intent state machine: on every animation frame, the system determines what the pointer is hovering over (a widget, a gap, empty space) and resolves an operation intent based on dwell time. A 2-frame hysteresis filter prevents flickering between zones. Five operation types are supported -- see Drag Strategies below.
API Reference
<DashboardProvider>
The root context provider. All hooks must be called within its subtree.
Props
| Prop | Type | Default | Description |
| ----------------------- | ------------------------------------------------------------ | -------------- | ------------------------------------------------------------------------------------------------------------------------------- |
| definitions | WidgetDefinition[] | (required) | Catalog of available widget types. |
| maxColumns | number | 2 | Number of grid columns. |
| gap | number | 16 | Gap in pixels between widgets. |
| maxWidgets | number | -- | Maximum number of widgets allowed. |
| maxUndoDepth | number | 50 | Maximum number of undo states to retain. |
| keyboardShortcuts | boolean | true | Enable Ctrl+Z / Ctrl+Y for undo/redo. |
| doubleClickToMaximize | boolean | true | Enable double-click on a drag handle to toggle a widget between its current span and full width (maxColumns). |
| enableExternalDrag | boolean | false | Enable HTML5 drag-to-add from external sources. Required for useExternalDragSource to work. See External Drag-to-Add. |
| canDrop | (sourceId, targetIndex, state) => boolean | -- | Custom drop validation. Return false to prevent a drop. |
| dragConfig | DragConfig | -- | Fine-tune drag activation, dwell times, scroll speed, and animation duration. See DragConfig. |
| responsiveBreakpoints | ResponsiveBreakpoints | -- | Customize breakpoints for getResponsiveColumns(). |
| onError | (error: DashboardError) => void | -- | Called when a validation error occurs (invalid widget type, exceeded max widgets, etc.). See Error Handling. |
| onDragStart | (event: { widgetId, phase }) => void | -- | Called when a drag begins. phase is 'pointer' or 'keyboard'. |
| onDragEnd | (event: { widgetId, operation, cancelled }) => void | -- | Called when a drag ends. Includes the committed operation and whether it was cancelled. |
| onWidgetAdd | (event: { widget }) => void | -- | Called after a widget is added. |
| onWidgetRemove | (event: { widgetId }) => void | -- | Called after a widget is removed. |
| onWidgetResize | (event: { widgetId, previousColSpan, newColSpan }) => void | -- | Called after a widget is resized. |
| onWidgetReorder | (event: { widgetId, fromIndex, toIndex }) => void | -- | Called after widgets are reordered. |
| onWidgetConfigChange | (event: { widgetId, config }) => void | -- | Called after a widget's config is updated. |
| onChange | (state: DashboardState) => void | -- | Called on every state change in both controlled and uncontrolled modes. |
| children | ReactNode | (required) | Child components. |
Uncontrolled mode (default):
| Prop | Type | Default | Description |
| ---------------- | --------------- | ------- | --------------------------------------------- |
| initialWidgets | WidgetState[] | [] | Seed the dashboard with pre-existing widgets. |
Controlled mode (pass both state and onStateChange):
| Prop | Type | Description |
| --------------- | -------------------------------------- | --------------------------------------------------------------------------------- |
| state | WidgetState[] | The widgets array managed externally. |
| onStateChange | (widgets: WidgetState[]) => void | Called with the next widgets array after every action. |
The two modes are mutually exclusive. In controlled mode, do not pass initialWidgets. In uncontrolled mode, do not pass state or onStateChange.
Note: Controlled mode passes only the
widgetsarray. Layout configuration (maxColumns,gap) is provided via top-level provider props, andcontainerWidthis managed internally as a transient measurement value.
useDashboard()
Returns the full dashboard context. Must be called inside <DashboardProvider>.
const {
state,
definitions,
layout,
actions,
canUndo,
canRedo,
phase,
dragState,
getDragPosition,
containerRef,
measureRef,
startDrag,
updateDragPointer,
endDrag,
getA11yProps,
handleKeyboardDrag,
isWidgetLockActive,
canAddWidget,
} = useDashboard();state: DashboardState
The current dashboard state.
| Field | Type | Description |
| ---------------- | --------------- | ------------------------------------------------------ |
| widgets | WidgetState[] | All widget instances (visible and hidden). |
| maxColumns | number | Current column count. |
| gap | number | Current gap in pixels. |
| containerWidth | number | Measured container width (0 before first measurement). |
definitions: WidgetDefinition[]
The definitions array passed to the provider.
layout: ComputedLayout
The computed layout for the current state.
| Field | Type | Description |
| ------------- | --------------------------- | ------------------------------------------------------------------------ |
| positions | Map<string, WidgetLayout> | Maps each visible widget's id to its computed position and dimensions. |
| totalHeight | number | Total height of the grid in pixels. Use this for the container's height. |
Each WidgetLayout contains:
| Field | Type | Description |
| --------- | -------- | ----------------------------------------------------------------------------------------- |
| id | string | Widget ID. |
| x | number | Horizontal offset in pixels from the container's left edge. |
| y | number | Vertical offset in pixels from the container's top edge. |
| width | number | Computed width in pixels (based on colSpan, maxColumns, gap, and containerWidth). |
| height | number | Measured height in pixels (or 200 if not yet measured). |
| colSpan | number | Number of columns this widget spans. |
actions: DashboardActions
Stable, memoized action dispatchers.
| Method | Signature | Description |
| -------------------- | ---------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- |
| addWidget | (widgetType: string, colSpan?: number, config?: Record<string, unknown>) => void | Add a new widget. If colSpan is omitted, uses the definition's defaultColSpan (or 1). |
| removeWidget | (id: string) => void | Remove a widget by ID. Respects remove lock. |
| resizeWidget | (id: string, colSpan: number) => void | Change a widget's column span. Clamped to [1, maxColumns]. Respects resize lock. |
| reorderWidgets | (fromIndex: number, toIndex: number) => void | Move a widget from one position to another (indices into the visible, sorted list). Clears all columnStart hints. |
| setMaxColumns | (maxColumns: number) => void | Change the column count. Widgets with a colSpan exceeding the new max are clamped. |
| batchUpdate | (widgets: WidgetState[]) => void | Replace the entire widgets array. Used internally by the drag system for swaps and resizes. |
| updateWidgetConfig | (id: string, config: Record<string, unknown>) => void | Shallow-merge into a widget's config object. |
| showWidget | (id: string) => void | Make a hidden widget visible again. |
| hideWidget | (id: string) => void | Soft-hide a widget (retained in state but removed from layout). |
| setWidgetLock | (id: string, lockType: LockType, locked: boolean) => void | Set or clear a lock on a widget instance. |
| undo | () => void | Undo the last undoable action. |
| redo | () => void | Redo the last undone action. |
dragState: DragState
The current drag state. Use this to render drag previews and ghosts.
| Field | Type | Description |
| ------------------- | ------------------------ | ---------------------------------------------------------------------------------------------------------------------- |
| activeId | string \| null | ID of the widget currently being dragged, or null. |
| dropTargetIndex | number \| null | The index where the widget would land if dropped now. |
| previewColSpan | number \| null | If the drop would resize the dragged widget, this is the new span. |
| previewLayout | ComputedLayout \| null | A full computed layout reflecting the tentative drop. Animate other widgets toward these positions for a live preview. |
| isLongPressing | boolean | Whether a touch long-press is in progress (before drag activation). |
| longPressTargetId | string \| null | Widget ID being long-pressed, or null. |
| isExternalDrag | boolean | Whether the current drag is from an external source (via useExternalDragSource). |
| externalWidgetType| string \| null | The widget type being dragged from an external source, or null. |
Refs, Drag Functions, and Utilities
| Name | Type | Description |
| -------------------- | ------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------- |
| containerRef | Ref<HTMLDivElement> | Attach to your grid container element. Used for width measurement and pointer coordinate mapping. |
| measureRef | (id: string) => (node: HTMLElement \| null) => void | Returns a callback ref for a widget. Attach to each widget's DOM node to enable height measurement. |
| startDrag | (id, pointerId, initialPos, element, pointerType?) => void | Call from a pointerdown handler to begin a drag. Respects position lock. |
| updateDragPointer | (pos: { x: number; y: number }) => void | Reserved for future use. Currently a no-op; pointer updates are handled automatically by the pointer adapter. |
| endDrag | () => void | Reserved for future use. Currently a no-op; drag cancellation is handled automatically (Escape key, pointer cancel, visibility change). |
| getDragPosition | () => { x: number; y: number } \| null | Returns the dragged widget's current position relative to the container, or null if not dragging. |
| getA11yProps | (widgetId: string) => DragHandleA11yProps | Get ARIA accessibility attributes for a drag handle. |
| handleKeyboardDrag | (widgetId: string, e: React.KeyboardEvent) => void | Handle keyboard events for keyboard-based dragging. Bind to the drag handle's onKeyDown. |
| isWidgetLockActive | (id: string, lockType: LockType) => boolean | Check whether a specific lock is active for a widget (considering both instance and definition locks). |
| canAddWidget | () => boolean | Check whether the maximum widget count has been reached. |
| canUndo | boolean | Whether an undo operation is available. |
| canRedo | boolean | Whether a redo operation is available. |
| phase | "idle" \| "pending" \| "dragging" \| "keyboard-dragging" \| "dropping" \| "external-dragging" | Current drag engine phase. "external-dragging" is active during HTML5 drag-to-add operations. |
useDashboardStable()
Returns only the stable context values -- state, layout, actions, and refs that do not change during a drag. Use this instead of useDashboard() in components that don't need drag state to avoid unnecessary re-renders during drag operations.
const {
state,
definitions,
layout,
actions,
canUndo,
canRedo,
getDragPosition,
containerRef,
measureRef,
startDrag,
updateDragPointer,
endDrag,
getA11yProps,
handleKeyboardDrag,
isWidgetLockActive,
canAddWidget,
doubleClickToMaximize,
registerTrashZone,
} = useDashboardStable();Returns the same fields as useDashboard() minus phase and dragState.
useDashboardDrag()
Returns only the volatile drag context values -- phase and dragState. These change frequently during drag operations. Use this in components that need to react to drag state (e.g., drop ghosts, drag overlays).
const { phase, dragState } = useDashboardDrag();| Field | Type | Description |
| ----------- | ------------------------------------------------------------------------------------------------ | -------------------------------- |
| phase | "idle" \| "pending" \| "dragging" \| "keyboard-dragging" \| "dropping" \| "external-dragging" | Current drag engine phase. |
| dragState | DragState | Current drag state for previews. |
Tip: Splitting
useDashboardStable()anduseDashboardDrag()across components lets you avoid re-rendering your entire widget tree on every drag frame. Components that only need actions or layout useuseDashboardStable(); components rendering drag ghosts useuseDashboardDrag().
useExternalDragSource(widgetType, options?)
Returns HTML5 drag props to make any element a drag source for adding widgets to the dashboard. Must be called inside <DashboardProvider>. Requires enableExternalDrag={true} on the provider.
import { useExternalDragSource } from "editable-dashboard";
function CatalogItem({ widgetType }: { widgetType: string }) {
const dragProps = useExternalDragSource(widgetType, {
colSpan: 2,
config: { chartType: "bar" },
onDragStart: () => console.log("drag started"),
onDragEnd: () => console.log("drag ended"),
});
return <div {...dragProps}>Drag to add</div>;
}Parameters
| Parameter | Type | Description |
| ---------------------- | ---------------------------- | -------------------------------------------------------------------------- |
| widgetType | string | Must match a WidgetDefinition.type registered on the provider. |
| options.colSpan | number? | Override the definition's defaultColSpan. |
| options.config | Record<string, unknown>? | Initial config to attach to the new widget instance. |
| options.onDragStart | () => void | Called when the user starts dragging this item. |
| options.onDragEnd | () => void | Called when the drag ends (drop, cancel, or escape). |
Returns: ExternalDragSourceProps
| Field | Type | Description |
| ------------- | --------------------------------- | -------------------------------------------- |
| draggable | true | HTML5 draggable attribute. |
| onDragStart | (e: React.DragEvent) => void | Initiates the external drag. |
| onDragEnd | (e: React.DragEvent) => void | Cleans up after drag ends. |
Spread all returned props onto your element. During the drag, existing widgets reflow to make room for the incoming widget (matching internal drag behavior), and a phantom widget (EXTERNAL_PHANTOM_ID) appears in the preview layout.
useTrashZone()
Headless hook that turns any element into a trash/cancel drop zone. Must be called inside <DashboardProvider>.
- During an internal pointer drag, dropping over the trash zone removes the widget.
- During an external HTML5 drag, dropping on the trash zone cancels the add.
import { useTrashZone } from "editable-dashboard";
function MyTrashZone() {
const { ref, isActive, isOver } = useTrashZone();
if (!isActive) return null;
return (
<div
ref={ref}
style={{
padding: 24,
background: isOver ? "rgba(239,68,68,0.2)" : "rgba(0,0,0,0.05)",
border: `2px dashed ${isOver ? "#ef4444" : "#999"}`,
textAlign: "center",
}}
>
{isOver ? "Release to remove" : "Drag here to remove"}
</div>
);
}Returns: TrashZoneResult
| Field | Type | Description |
| ---------- | --------------------------- | ------------------------------------------------------------------- |
| ref | React.RefCallback<HTMLElement> | Attach to the element that acts as the trash zone. |
| isActive | boolean | true when any drag (internal or external) is in progress. |
| isOver | boolean | true when the dragged widget is hovering over the trash zone. |
Serialization
import {
serializeDashboard,
deserializeDashboard,
validateSerializedDashboard,
CURRENT_SERIALIZATION_VERSION,
} from "editable-dashboard";serializeDashboard(state: DashboardState): SerializedDashboard
Produces a compact JSON-safe snapshot. Strips transient containerWidth and omits optional fields that hold default values (e.g., lockPosition: false is omitted).
deserializeDashboard(data: SerializedDashboard, definitions: WidgetDefinition[]): DashboardState
Rebuilds a DashboardState from a snapshot with full validation:
- Validates all required fields and throws descriptive errors for invalid input
- Widgets whose
typehas no matching definition are silently dropped - Duplicate widget IDs are deduplicated (first occurrence wins)
colSpanvalues are clamped to[1, maxColumns]- Supports schema version 1 (migrates
lockedtolockPosition) and version 2
validateSerializedDashboard(data: unknown): { valid: boolean; errors: string[] }
Validates the structure of serialized data without throwing. Use this to guard untrusted input before deserializing:
const raw = JSON.parse(userInput);
const { valid, errors } = validateSerializedDashboard(raw);
if (!valid) {
console.error("Invalid dashboard data:", errors);
return;
}
const restored = deserializeDashboard(raw, definitions);CURRENT_SERIALIZATION_VERSION
The current schema version (currently 2). Use for version checks or when building custom serialization.
// Save
const snapshot = serializeDashboard(state);
localStorage.setItem("dashboard", JSON.stringify(snapshot));
// Restore
const raw = JSON.parse(localStorage.getItem("dashboard")!);
const restored = deserializeDashboard(raw, definitions);SerializedDashboard
| Field | Type | Description |
| ------------ | --------------- | ------------------------------- |
| version | number | Schema version (currently 2). |
| widgets | WidgetState[] | All widget instances. |
| maxColumns | number | Column count. |
| gap | number | Gap in pixels. |
Standalone Layout Function
import { computeLayout } from "editable-dashboard";computeLayout(widgets, heights, containerWidth, maxColumns, gap): ComputedLayout
Run the layout algorithm outside of React (useful for server-side rendering, testing, or preview thumbnails).
| Parameter | Type | Description |
| ---------------- | --------------------- | ------------------------------------------------------------------- |
| widgets | WidgetState[] | The widget instances to lay out. |
| heights | Map<string, number> | Known heights for each widget ID. Missing entries default to 200. |
| containerWidth | number | Container width in pixels. |
| maxColumns | number | Number of columns. |
| gap | number | Gap between widgets in pixels. |
Returns a ComputedLayout with positions and totalHeight.
Responsive Column Helper
import { getResponsiveColumns } from "editable-dashboard";getResponsiveColumns(containerWidth, breakpoints?): number
Determine the appropriate column count for a given container width.
| Parameter | Type | Default | Description |
| ---------------- | ------------------------ | -------------------------------- | ---------------------------------- |
| containerWidth | number | -- | Current container width in pixels. |
| breakpoints | ResponsiveBreakpoints? | { sm: 480, md: 768, lg: 1024 } | Custom breakpoint thresholds. |
Returns 1 column below sm, 2 below md, 3 below lg, and 4 at or above lg.
Types
WidgetDefinition
Describes a category/type of widget available in the catalog.
| Field | Type | Default | Description |
| ---------------- | ---------- | ------- | ---------------------------------------------------------------------------------------------- |
| type | string | -- | Unique identifier for this widget type. |
| label | string | -- | Human-readable display name. |
| defaultColSpan | number | -- | Default column span when adding a new instance. |
| minColSpan | number? | -- | Minimum allowed column span. |
| maxColSpan | number? | -- | Maximum allowed column span. |
| lockPosition | boolean? | false | When true, all instances are locked from being dragged by default. Overridable per-instance. |
| lockResize | boolean? | false | When true, all instances are locked from being resized by default. Overridable per-instance. |
| lockRemove | boolean? | false | When true, all instances are locked from being removed by default. Overridable per-instance. |
WidgetState
Represents a single widget instance on the dashboard.
| Field | Type | Description |
| -------------- | -------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| id | string | Unique instance ID (typically a UUID). |
| type | string | References a WidgetDefinition.type. |
| colSpan | number | Current column span. |
| visible | boolean | Whether the widget is visible on the grid. Set to false for soft-hide (retained in state but excluded from layout). Toggle with showWidget/hideWidget actions. |
| order | number | Sort order (lower values appear first). |
| columnStart | number? | Column hint -- forces the widget to start at a specific column. Set by column-shift drags; cleared on reorder. |
| config | Record<string, unknown>? | Arbitrary per-widget configuration. |
| lockPosition | boolean? | Per-instance position lock override. Takes precedence over definition. |
| lockResize | boolean? | Per-instance resize lock override. Takes precedence over definition. |
| lockRemove | boolean? | Per-instance remove lock override. Takes precedence over definition. |
DashboardStateInput
The externally-facing state type used in controlled mode. Contains only the widget instances.
| Field | Type | Description |
| --------- | --------------- | --------------------- |
| widgets | WidgetState[] | All widget instances. |
DashboardState
Complete internal state of the dashboard, including layout configuration and transient runtime data.
| Field | Type | Description |
| ---------------- | --------------- | ------------------------------------------------------------------------- |
| widgets | WidgetState[] | All widget instances. |
| maxColumns | number | Current column count. |
| gap | number | Gap in pixels. |
| containerWidth | number | Measured container width (transient, managed internally, not serialized). |
ComputedLayout
| Field | Type | Description |
| ------------- | --------------------------- | --------------------------------------------------- |
| positions | Map<string, WidgetLayout> | Computed position and size for each visible widget. |
| totalHeight | number | Total height of the grid. |
WidgetLayout
| Field | Type | Description |
| --------- | -------- | -------------------------------- |
| id | string | Widget ID. |
| x | number | Horizontal offset (px). |
| y | number | Vertical offset (px). |
| width | number | Computed width (px). |
| height | number | Measured or default height (px). |
| colSpan | number | Effective column span. |
DragState
| Field | Type | Description |
| ----------------- | ------------------------ | ----------------------------------------------------------------------------------------------------------------- |
| activeId | string \| null | ID of the dragged widget. |
| dropTargetIndex | number \| null | Target insertion index. |
| previewColSpan | number \| null | Dragged widget's span in the preview (if resize is involved). |
| previewLayout | ComputedLayout \| null | Full layout for the tentative drop position. |
| intentType | string \| null | Current drag intent: "reorder", "swap", "auto-resize", "column-pin", "empty-row-maximize", or "none". |
DropTarget
| Field | Type | Description |
| ----------------- | ---------------------------------------- | ---------------------------------------------------------------- |
| targetIndex | number | Insertion index in the visible-sorted list. |
| previewColSpan | number \| null | New column span for the dragged widget (or null if unchanged). |
| affectedResizes | Array<{ id: string; colSpan: number }> | Other widgets that would be resized. |
| columnStart | number? | Column hint for column-shift drops. |
| swapWithId | string? | ID of the widget to swap with (for cross-row swaps). |
DragHandleProps
Props to spread onto a drag handle element.
| Field | Type | Description |
| ---------------------- | ----------------------------------------- | ----------------------------------------------------------- |
| onPointerDown | (e: ReactPointerEvent) => void | Initiates the drag on pointer down. |
| onKeyDown | (e: React.KeyboardEvent) => void | Keyboard interaction handler. |
| onDoubleClick | (e: React.MouseEvent) => void | undefined | Double-click handler for maximize toggle. Present when doubleClickToMaximize is true. |
| style | { cursor: string; touchAction: string } | Sets cursor: grab/grabbing and touchAction: none. |
| role | 'button' | ARIA role. |
| tabIndex | 0 | Makes the handle focusable. |
| aria-roledescription | 'sortable' | Accessibility description. |
| aria-label | string | Accessible label for the drag handle. |
| aria-pressed | boolean? | Present when the widget is in keyboard-drag mode. |
| aria-describedby | string? | ID of the live-region element providing drag announcements. |
WidgetSlotRenderProps
Props passed to widget slot render functions.
| Field | Type | Description |
| ----------------- | --------------------------- | ---------------------------------------------- |
| widget | WidgetState | The widget instance. |
| dragHandleProps | DragHandleProps | Spread these onto your drag handle. |
| isDragging | boolean | Whether this widget is actively being dragged. |
| colSpan | number | Current column span. |
| resize | (colSpan: number) => void | Resize this widget. |
| remove | () => void | Remove this widget. |
DashboardError
Typed error object passed to the onError callback.
| Field | Type | Description |
| --------- | -------------------------- | ---------------------------- |
| code | string | Machine-readable error code. |
| message | string | Human-readable description. |
| context | Record<string, unknown>? | Optional debugging context. |
Error codes:
| Code | When |
| --------------------------- | ---------------------------------------------------------------------------------- |
| INVALID_DEFINITIONS | Empty definitions array. |
| DUPLICATE_DEFINITION_TYPE | Two definitions share the same type. |
| INVALID_DEFAULT_COL_SPAN | A definition's defaultColSpan is less than 1. |
| INVALID_MAX_COLUMNS | maxColumns is 0 or negative. |
| INVALID_GAP | gap is negative. |
| INVALID_MAX_UNDO_DEPTH | maxUndoDepth is 0 or negative. |
| INVALID_WIDGET_TYPE | Attempted to add a widget with an unknown type. |
| MAX_WIDGETS_REACHED | Attempted to add a widget beyond maxWidgets limit. |
| INVALID_COL_SPAN | resizeWidget called with a span outside the valid range (clamped automatically). |
| INVALID_REORDER_INDEX | reorderWidgets called with out-of-bounds indices. |
| INVALID_SERIALIZED_DATA | Deserialization input fails structural validation. |
DragConfig
Fine-tune drag activation, dwell timing, and scroll behavior.
| Field | Type | Default | Description |
| ----------------------- | --------- | ------- | -------------------------------------------------------- |
| activationThreshold | number? | 5 | Min pointer movement (px) before drag activates. |
| touchActivationDelay | number? | 200 | Touch long-press delay (ms). |
| touchMoveTolerance | number? | 10 | Max pointer drift (px) during long-press. |
| autoScrollEdgeSize | number? | 60 | Distance from viewport edge (px) to trigger auto-scroll. |
| autoScrollMaxSpeed | number? | 15 | Max auto-scroll speed (px/frame). |
| swapDwellMs | number? | 0 | Dwell time (ms) before cross-row swap activates. |
| resizeDwellMs | number? | 600 | Dwell time (ms) before auto-resize activates. |
| dropAnimationDuration | number? | 250 | Duration of the drop animation (ms). |
ResponsiveBreakpoints
Breakpoint widths (in pixels) for responsive column count. Used by getResponsiveColumns() and the responsiveBreakpoints prop.
| Field | Type | Default | Description |
| ----- | --------- | ------- | ---------------------------------------------------- |
| sm | number? | 480 | Below this width: 1 column. |
| md | number? | 768 | Below this width: 2 columns. |
| lg | number? | 1024 | Below this width: 3 columns; at or above: 4 columns. |
DashboardProviderProps
See <DashboardProvider> Props above.
DashboardContextValue
The full shape returned by useDashboard(). See the useDashboard() section.
DashboardAction
The discriminated union of all reducer actions:
type DashboardAction =
| {
type: "ADD_WIDGET";
widgetType: string;
colSpan: number;
config?: Record<string, unknown>;
targetIndex?: number; // Insert at a specific position instead of appending
columnStart?: number; // Force column placement for the new widget
}
| { type: "REMOVE_WIDGET"; id: string }
| { type: "RESIZE_WIDGET"; id: string; colSpan: number }
| { type: "REORDER_WIDGETS"; fromIndex: number; toIndex: number }
| { type: "SET_MAX_COLUMNS"; maxColumns: number }
| { type: "BATCH_UPDATE"; widgets: WidgetState[] }
| {
type: "UPDATE_WIDGET_CONFIG";
id: string;
config: Record<string, unknown>;
}
| { type: "SWAP_WIDGETS"; sourceId: string; targetId: string }
| {
type: "SET_WIDGET_LOCK";
id: string;
lockType: LockType;
locked: boolean;
}
| { type: "SHOW_WIDGET"; id: string }
| { type: "HIDE_WIDGET"; id: string }
| { type: "UNDO" }
| { type: "REDO" };DashboardActions
The memoized action dispatchers object. See the actions table.
ExternalDragItem
Describes a widget being dragged from an external source.
| Field | Type | Description |
| ------------ | -------------------------- | ------------------------------------------------------------- |
| widgetType | string | Must match a WidgetDefinition.type. |
| colSpan | number? | Column span override. Falls back to defaultColSpan. |
| config | Record<string, unknown>? | Initial config to attach to the new widget. |
ExternalDragSourceProps
Props returned by useExternalDragSource() to spread onto a draggable element.
| Field | Type | Description |
| ------------- | --------------------------------- | ------------------------------ |
| draggable | true | HTML5 draggable attribute. |
| onDragStart | (e: React.DragEvent) => void | Initiates the external drag. |
| onDragEnd | (e: React.DragEvent) => void | Cleans up after drag ends. |
ExternalDropEvent
Event payload after an external widget is dropped onto the dashboard.
| Field | Type | Description |
| ------------- | -------------------------- | ----------------------------------------------- |
| widgetType | string | The dropped widget's type. |
| widgetId | string | The newly created widget's ID. |
| colSpan | number | Final column span. |
| targetIndex | number | Insertion index in the visible-sorted list. |
| columnStart | number? | Column hint if pinned during drag. |
| config | Record<string, unknown>? | Config passed from the drag source. |
TrashZoneResult
Return type of useTrashZone().
| Field | Type | Description |
| ---------- | -------------------------------- | -------------------------------------------------------------- |
| ref | React.RefCallback<HTMLElement> | Attach to the trash zone element. |
| isActive | boolean | true when any drag (internal or external) is in progress. |
| isOver | boolean | true when the dragged widget is hovering over the trash zone.|
Constants
Exported default values and thresholds:
| Constant | Value | Description |
| --------------------------- | -------------------------- | ---------------------------------------------------------------------------- |
| DEFAULT_MAX_COLUMNS | 2 | Default column count. |
| DEFAULT_GAP | 16 | Default gap in pixels. |
| DEFAULT_WIDGET_HEIGHT | 200 | Fallback height before measurement. |
| DRAG_ACTIVATION_THRESHOLD | 5 | Minimum pointer movement (px) before drag activates. |
| EXTERNAL_PHANTOM_ID | "__external_phantom__" | Widget ID used in preview layouts for the incoming external drag phantom. |
Layout Engine
How Bin-Packing Works
The layout runs a greedy column-height algorithm:
- Maintain an array of column heights (one per column), initialized to 0.
- For each visible widget (sorted by
order):- Compute the widget's effective
colSpan(clamped to[1, maxColumns]). - Scan every valid start column. For each, the Y position is the maximum column height across the columns the widget would occupy.
- Pick the start column with the lowest Y value (leftmost wins on ties).
- If the widget has a
columnStarthint, use that column instead. - Place the widget at
(x, y)and update column heights.
- Compute the widget's effective
totalHeightis the maximum column height minus one gap.
Column Configuration
Set maxColumns on the provider or call actions.setMaxColumns(n) at runtime. When the column count decreases, widgets whose colSpan exceeds the new max are automatically clamped.
Widget Sizing
Each widget's pixel width is calculated as:
width = colSpan * colWidth + (colSpan - 1) * gapwhere:
colWidth = (containerWidth - gap * (maxColumns - 1)) / maxColumnsHeight Measurement
Widgets are measured automatically by a ResizeObserver attached via measureRef. The observer batches updates using requestAnimationFrame and only triggers a re-render when heights actually change. Before the first measurement, widgets use a fallback height of 200px.
Drag & Drop
Wiring Up Drag Handles
The simplest way to make a widget draggable:
function MyWidget({ widget }: { widget: WidgetState }) {
const { startDrag } = useDashboard();
return (
<div>
<button
onPointerDown={(e) => {
e.preventDefault();
startDrag(
widget.id,
e.pointerId,
{ x: e.clientX, y: e.clientY },
e.currentTarget as HTMLElement,
);
}}
style={{ cursor: "grab", touchAction: "none" }}
>
Drag me
</button>
<div>Content here</div>
</div>
);
}Key points:
- Call
e.preventDefault()to stop text selection and default touch behaviors. - Set
touchAction: "none"on the handle for reliable pointer events on mobile. - The drag activates only after the pointer moves at least 5px from the initial click.
- Press Escape at any time to cancel a drag.
Drag Strategies
The drag system resolves a zone (what the pointer is over) and maps it to an operation intent based on dwell time:
| Strategy | What It Does | When It Activates |
| -------------------- | --------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------- |
| Reorder (Insert) | Moves the widget to a new position; others shift to fill the gap. | Pointer enters a gap zone between widgets. |
| Swap | Exchanges positions of the dragged widget and a target in a different row. | Pointer dwells on a widget (swapDwellMs, default: immediate). |
| Side-drop | Resizes one peer and the dragged widget so they share a row. | Pointer dwells on a widget longer (resizeDwellMs) and combined spans exceed maxColumns. |
| Row squeeze | Resizes all widgets in a row to make room for the dragged widget. | Same as side-drop, but multiple peers are in the target row. |
| Column pin | Slides the widget to a different column within the same row (sets columnStart). | Pointer enters empty space in the grid. |
A 2-frame hysteresis on zone changes prevents the preview from flickering when the pointer oscillates near boundaries.
For detailed ASCII diagrams of every scenario, see docs/drag-behaviors.md.
Building Your Grid Component
Here is a step-by-step guide to building a custom animated grid using Framer Motion (motion/react):
Step 1: The Grid Container
import { motion, AnimatePresence, LayoutGroup } from "motion/react";
import { useDashboard, type WidgetState } from "editable-dashboard";
function DashboardGrid({
children,
}: {
children: (widget: WidgetState, isDragging: boolean) => React.ReactNode;
}) {
const { state, layout, dragState, containerRef } = useDashboard();
const visibleWidgets = state.widgets
.filter((w) => w.visible)
.sort((a, b) => a.order - b.order);
// Use the preview layout during drag for smooth transitions
const activeLayout = dragState.previewLayout ?? layout;
return (
<LayoutGroup>
<div
ref={containerRef}
style={{
position: "relative",
height: activeLayout.totalHeight || "auto",
minHeight: 100,
}}
>
{/* Drop ghost (shows where the widget will land) */}
<AnimatePresence>
{dragState.activeId &&
dragState.previewLayout &&
(() => {
const ghostPos =
dragState.previewLayout.positions.get(
dragState.activeId!,
);
if (!ghostPos) return null;
return (
<motion.div
key="drop-ghost"
initial={{ opacity: 0 }}
animate={{
opacity: 1,
x: ghostPos.x,
y: ghostPos.y,
width: ghostPos.width,
height: ghostPos.height,
}}
exit={{ opacity: 0 }}
transition={{
type: "spring",
stiffness: 300,
damping: 30,
mass: 0.8,
}}
style={{
position: "absolute",
left: 0,
top: 0,
borderRadius: 12,
background: "rgba(59, 130, 246, 0.08)",
border: "2px dashed rgba(59, 130, 246, 0.3)",
pointerEvents: "none",
}}
/>
);
})()}
</AnimatePresence>
{/* Widget slots */}
<AnimatePresence mode="popLayout">
{visibleWidgets.map((widget) => (
<WidgetSlot key={widget.id} widget={widget}>
{children}
</WidgetSlot>
))}
</AnimatePresence>
</div>
</LayoutGroup>
);
}Step 2: The Widget Slot
import { useCallback, useEffect, useRef } from "react";
import { motion, useMotionValue, animate } from "motion/react";
import { useDashboard, type WidgetState } from "editable-dashboard";
const SPRING = {
type: "spring" as const,
stiffness: 300,
damping: 30,
mass: 0.8,
};
function WidgetSlot({
widget,
children,
}: {
widget: WidgetState;
children: (widget: WidgetState, isDragging: boolean) => React.ReactNode;
}) {
const {
layout,
actions,
dragState,
getDragPosition,
measureRef,
startDrag,
} = useDashboard();
const isDragging = dragState.activeId === widget.id;
const isAnyDragging = dragState.activeId !== null;
// During someone else's drag, use preview positions for smooth shifting
const previewPos = dragState.previewLayout?.positions.get(widget.id);
const normalPos = layout.positions.get(widget.id);
const position =
isAnyDragging && !isDragging && previewPos ? previewPos : normalPos;
// Track the dragged widget's position with motion values for 60fps updates
const motionX = useMotionValue(0);
const motionY = useMotionValue(0);
const rafId = useRef(0);
useEffect(() => {
if (!isDragging || !position) {
cancelAnimationFrame(rafId.current);
animate(motionX, 0, SPRING);
animate(motionY, 0, SPRING);
return;
}
const tick = () => {
const dp = getDragPosition();
if (dp && position) {
motionX.set(dp.x 