Skip to main content
Back to Blog
Tutorials
3 min read
December 13, 2024

How to Build a Dashboard with Drag-and-Drop Widgets in React

Create a customizable dashboard with resizable, draggable widget cards using react-grid-layout in React.

Ryel Banfield

Founder & Lead Developer

Customizable dashboards let users arrange widgets the way they want. Here is how to build one with react-grid-layout.

Install Dependencies

pnpm add react-grid-layout
pnpm add -D @types/react-grid-layout

Define Widget Types

// types/dashboard.ts
export interface Widget {
  id: string;
  type: "metric" | "chart" | "table" | "activity" | "notes";
  title: string;
  config: Record<string, unknown>;
}

export interface DashboardLayout {
  lg: ReactGridLayout.Layout[];
  md: ReactGridLayout.Layout[];
  sm: ReactGridLayout.Layout[];
}

export interface DashboardConfig {
  id: string;
  name: string;
  widgets: Widget[];
  layouts: DashboardLayout;
}

Dashboard Component

"use client";

import { useCallback, useState } from "react";
import { Responsive, WidthProvider } from "react-grid-layout";
import type { Layouts, Layout } from "react-grid-layout";
import "react-grid-layout/css/styles.css";
import "react-resizable/css/styles.css";

import type { Widget, DashboardConfig } from "@/types/dashboard";
import { MetricWidget } from "./widgets/MetricWidget";
import { ChartWidget } from "./widgets/ChartWidget";
import { TableWidget } from "./widgets/TableWidget";
import { ActivityWidget } from "./widgets/ActivityWidget";
import { NotesWidget } from "./widgets/NotesWidget";

const ResponsiveGrid = WidthProvider(Responsive);

const WIDGET_COMPONENTS: Record<Widget["type"], React.ComponentType<{ widget: Widget }>> = {
  metric: MetricWidget,
  chart: ChartWidget,
  table: TableWidget,
  activity: ActivityWidget,
  notes: NotesWidget,
};

interface DashboardProps {
  config: DashboardConfig;
  onSave: (config: DashboardConfig) => void;
}

export function CustomizableDashboard({ config, onSave }: DashboardProps) {
  const [widgets, setWidgets] = useState(config.widgets);
  const [layouts, setLayouts] = useState<Layouts>(config.layouts);
  const [isEditing, setIsEditing] = useState(false);

  const handleLayoutChange = useCallback(
    (_current: Layout[], allLayouts: Layouts) => {
      setLayouts(allLayouts);
    },
    []
  );

  const handleSave = () => {
    const updated = { ...config, widgets, layouts: layouts as DashboardConfig["layouts"] };
    onSave(updated);
    setIsEditing(false);
  };

  const addWidget = (type: Widget["type"]) => {
    const id = `widget-${Date.now()}`;
    const newWidget: Widget = {
      id,
      type,
      title: `New ${type} widget`,
      config: {},
    };

    setWidgets((prev) => [...prev, newWidget]);

    // Add to layouts at bottom
    const newLayoutItem: Layout = {
      i: id,
      x: 0,
      y: Infinity, // react-grid-layout places at bottom
      w: type === "metric" ? 3 : 6,
      h: type === "metric" ? 2 : 4,
      minW: 2,
      minH: 2,
    };

    setLayouts((prev) => ({
      lg: [...(prev.lg || []), newLayoutItem],
      md: [...(prev.md || []), { ...newLayoutItem, w: Math.min(newLayoutItem.w, 4) }],
      sm: [...(prev.sm || []), { ...newLayoutItem, w: 2 }],
    }));
  };

  const removeWidget = (id: string) => {
    setWidgets((prev) => prev.filter((w) => w.id !== id));
    setLayouts((prev) => ({
      lg: (prev.lg || []).filter((l) => l.i !== id),
      md: (prev.md || []).filter((l) => l.i !== id),
      sm: (prev.sm || []).filter((l) => l.i !== id),
    }));
  };

  return (
    <div>
      {/* Toolbar */}
      <div className="flex items-center justify-between mb-4">
        <h1 className="text-xl font-bold">{config.name}</h1>
        <div className="flex gap-2">
          {isEditing && (
            <>
              <WidgetPicker onAdd={addWidget} />
              <button
                onClick={handleSave}
                className="px-3 py-1.5 bg-primary text-primary-foreground text-sm rounded"
              >
                Save layout
              </button>
            </>
          )}
          <button
            onClick={() => (isEditing ? handleSave() : setIsEditing(true))}
            className="px-3 py-1.5 border text-sm rounded hover:bg-muted"
          >
            {isEditing ? "Done" : "Customize"}
          </button>
        </div>
      </div>

      {/* Grid */}
      <ResponsiveGrid
        className="layout"
        layouts={layouts}
        breakpoints={{ lg: 1200, md: 768, sm: 0 }}
        cols={{ lg: 12, md: 8, sm: 2 }}
        rowHeight={80}
        isDraggable={isEditing}
        isResizable={isEditing}
        onLayoutChange={handleLayoutChange}
        draggableHandle=".widget-handle"
        compactType="vertical"
        margin={[16, 16]}
      >
        {widgets.map((widget) => {
          const WidgetComponent = WIDGET_COMPONENTS[widget.type];
          return (
            <div key={widget.id} className="bg-card border rounded-lg overflow-hidden">
              {/* Widget Header */}
              <div className="flex items-center justify-between px-3 py-2 border-b">
                <span
                  className={`text-sm font-medium ${isEditing ? "widget-handle cursor-grab" : ""}`}
                >
                  {isEditing && (
                    <span className="inline-block mr-1.5 text-muted-foreground">
                      <svg className="w-4 h-4 inline" fill="currentColor" viewBox="0 0 24 24">
                        <circle cx="9" cy="6" r="1.5" /><circle cx="15" cy="6" r="1.5" />
                        <circle cx="9" cy="12" r="1.5" /><circle cx="15" cy="12" r="1.5" />
                        <circle cx="9" cy="18" r="1.5" /><circle cx="15" cy="18" r="1.5" />
                      </svg>
                    </span>
                  )}
                  {widget.title}
                </span>
                {isEditing && (
                  <button
                    onClick={() => removeWidget(widget.id)}
                    className="text-muted-foreground hover:text-destructive text-xs"
                    aria-label="Remove widget"
                  >
                    Remove
                  </button>
                )}
              </div>

              {/* Widget Content */}
              <div className="p-3 h-[calc(100%-41px)] overflow-auto">
                <WidgetComponent widget={widget} />
              </div>
            </div>
          );
        })}
      </ResponsiveGrid>
    </div>
  );
}

Widget Picker

function WidgetPicker({ onAdd }: { onAdd: (type: Widget["type"]) => void }) {
  const [open, setOpen] = useState(false);

  const widgetTypes: { type: Widget["type"]; label: string; description: string }[] = [
    { type: "metric", label: "Metric Card", description: "Single KPI with trend" },
    { type: "chart", label: "Chart", description: "Line, bar, or area chart" },
    { type: "table", label: "Data Table", description: "Tabular data view" },
    { type: "activity", label: "Activity Feed", description: "Recent events log" },
    { type: "notes", label: "Notes", description: "Editable text notes" },
  ];

  return (
    <div className="relative">
      <button
        onClick={() => setOpen(!open)}
        className="px-3 py-1.5 border text-sm rounded hover:bg-muted"
      >
        + Add widget
      </button>
      {open && (
        <div className="absolute right-0 top-full mt-1 w-64 bg-popover border rounded-lg shadow-lg z-10 p-2">
          {widgetTypes.map(({ type, label, description }) => (
            <button
              key={type}
              onClick={() => { onAdd(type); setOpen(false); }}
              className="w-full text-left px-3 py-2 rounded hover:bg-muted"
            >
              <div className="text-sm font-medium">{label}</div>
              <div className="text-xs text-muted-foreground">{description}</div>
            </button>
          ))}
        </div>
      )}
    </div>
  );
}

Sample Metric Widget

// widgets/MetricWidget.tsx
import type { Widget } from "@/types/dashboard";

export function MetricWidget({ widget }: { widget: Widget }) {
  const value = (widget.config.value as number) ?? 1234;
  const change = (widget.config.change as number) ?? 12.5;
  const isPositive = change >= 0;

  return (
    <div className="flex flex-col justify-center h-full">
      <div className="text-3xl font-bold">
        {typeof value === "number" ? value.toLocaleString() : value}
      </div>
      <div className={`text-sm mt-1 ${isPositive ? "text-green-600" : "text-red-600"}`}>
        {isPositive ? "+" : ""}
        {change}% from last period
      </div>
    </div>
  );
}

Persist Layout

// lib/dashboard-storage.ts
import type { DashboardConfig } from "@/types/dashboard";

const STORAGE_KEY = "dashboard-config";

export function saveDashboardConfig(config: DashboardConfig): void {
  localStorage.setItem(STORAGE_KEY, JSON.stringify(config));
}

export function loadDashboardConfig(): DashboardConfig | null {
  const stored = localStorage.getItem(STORAGE_KEY);
  if (!stored) return null;
  try {
    return JSON.parse(stored) as DashboardConfig;
  } catch {
    return null;
  }
}

Need a Custom Dashboard?

We build data-driven dashboards for businesses. Contact us to get started.

dashboarddrag-and-dropwidgetsReacttutorial

Ready to Start Your Project?

RCB Software builds world-class websites and applications for businesses worldwide.

Get in Touch

Related Articles