Skip to content
Go back

Building a Drag-and-Drop Dashboard in React

Building a Drag-and-Drop Dashboard in React

Introduction

Interactive dashboards with drag-and-drop functionality enhance user experience. This guide shows how to build a customizable dashboard using React DnD.

Prerequisites

Step 1: Install Dependencies

npm install react-dnd react-dnd-html5-backend uuid
npm install --save-dev @types/uuid

Step 2: Setup DnD Provider

Update app/layout.tsx:

'use client'

import { DndProvider } from 'react-dnd';
import { HTML5Backend } from 'react-dnd-html5-backend';

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="en">
      <body>
        <DndProvider backend={HTML5Backend}>
          {children}
        </DndProvider>
      </body>
    </html>
  );
}

Step 3: Define Widget Types

Create types/dashboard.ts:

export interface Widget {
  id: string;
  type: "chart" | "stats" | "table" | "text";
  title: string;
  x: number;
  y: number;
  width: number;
  height: number;
  data?: any;
}

export interface DragItem {
  type: string;
  id: string;
  index: number;
}

Step 4: Create Draggable Widget Component

Create components/DraggableWidget.tsx:

import { useDrag } from 'react-dnd';
import { Widget } from '@/types/dashboard';

interface DraggableWidgetProps {
  widget: Widget;
  index: number;
  onMove: (dragIndex: number, hoverIndex: number) => void;
  children: React.ReactNode;
}

export default function DraggableWidget({
  widget,
  index,
  onMove,
  children,
}: DraggableWidgetProps) {
  const [{ isDragging }, drag] = useDrag({
    type: 'widget',
    item: { type: 'widget', id: widget.id, index },
    collect: (monitor) => ({
      isDragging: monitor.isDragging(),
    }),
  });

  return (
    <div
      ref={drag}
      className={`bg-white rounded-lg shadow-md border transition-all cursor-move ${
        isDragging ? 'opacity-50 rotate-2' : 'hover:shadow-lg'
      }`}
      style={{
        width: widget.width,
        height: widget.height,
      }}
    >
      <div className="p-4 border-b border-gray-200 bg-gray-50 rounded-t-lg">
        <h3 className="font-semibold text-gray-800">{widget.title}</h3>
        <div className="flex space-x-1 mt-1">
          <div className="w-2 h-2 bg-red-500 rounded-full"></div>
          <div className="w-2 h-2 bg-yellow-500 rounded-full"></div>
          <div className="w-2 h-2 bg-green-500 rounded-full"></div>
        </div>
      </div>
      <div className="p-4">{children}</div>
    </div>
  );
}

Step 5: Create Drop Zone

Create components/DropZone.tsx:

import { useDrop } from 'react-dnd';
import { DragItem } from '@/types/dashboard';

interface DropZoneProps {
  onDrop: (item: DragItem, targetIndex: number) => void;
  index: number;
  children: React.ReactNode;
}

export default function DropZone({ onDrop, index, children }: DropZoneProps) {
  const [{ isOver, canDrop }, drop] = useDrop({
    accept: 'widget',
    drop: (item: DragItem) => onDrop(item, index),
    collect: (monitor) => ({
      isOver: monitor.isOver(),
      canDrop: monitor.canDrop(),
    }),
  });

  const isActive = canDrop && isOver;

  return (
    <div
      ref={drop}
      className={`min-h-[200px] border-2 border-dashed rounded-lg transition-colors ${
        isActive
          ? 'border-blue-500 bg-blue-50'
          : canDrop
          ? 'border-gray-300 bg-gray-50'
          : 'border-gray-200'
      }`}
    >
      {children}
      {isActive && (
        <div className="absolute inset-0 flex items-center justify-center bg-blue-100 bg-opacity-75 rounded-lg">
          <p className="text-blue-600 font-medium">Drop widget here</p>
        </div>
      )}
    </div>
  );
}

Step 6: Create Widget Components

Create components/widgets/ChartWidget.tsx:

export default function ChartWidget({ data }: { data?: any }) {
  return (
    <div className="h-full flex items-center justify-center">
      <div className="w-full h-32 bg-gradient-to-r from-blue-400 to-purple-500 rounded flex items-end p-2">
        {[40, 70, 45, 60, 85].map((height, i) => (
          <div
            key={i}
            className="bg-white bg-opacity-80 rounded-sm mr-1 flex-1"
            style={{ height: `${height}%` }}
          />
        ))}
      </div>
    </div>
  );
}

Create components/widgets/StatsWidget.tsx:

export default function StatsWidget({ data }: { data?: any }) {
  const stats = data || { value: '42.5K', label: 'Users', change: '+12%' };

  return (
    <div className="text-center">
      <div className="text-3xl font-bold text-gray-800">{stats.value}</div>
      <div className="text-gray-600">{stats.label}</div>
      <div className="text-green-600 text-sm font-medium">{stats.change}</div>
    </div>
  );
}

Step 7: Create Dashboard Manager

Create components/Dashboard.tsx:

import { useState } from 'react';
import { v4 as uuidv4 } from 'uuid';
import DraggableWidget from './DraggableWidget';
import DropZone from './DropZone';
import ChartWidget from './widgets/ChartWidget';
import StatsWidget from './widgets/StatsWidget';
import { Widget, DragItem } from '@/types/dashboard';

const initialWidgets: Widget[] = [
  {
    id: uuidv4(),
    type: 'stats',
    title: 'Total Users',
    x: 0,
    y: 0,
    width: 300,
    height: 200,
  },
  {
    id: uuidv4(),
    type: 'chart',
    title: 'Revenue Chart',
    x: 320,
    y: 0,
    width: 400,
    height: 200,
  },
];

export default function Dashboard() {
  const [widgets, setWidgets] = useState<Widget[]>(initialWidgets);

  const moveWidget = (dragIndex: number, hoverIndex: number) => {
    const draggedWidget = widgets[dragIndex];
    const newWidgets = [...widgets];
    newWidgets.splice(dragIndex, 1);
    newWidgets.splice(hoverIndex, 0, draggedWidget);
    setWidgets(newWidgets);
  };

  const handleDrop = (item: DragItem, targetIndex: number) => {
    moveWidget(item.index, targetIndex);
  };

  const renderWidget = (widget: Widget) => {
    switch (widget.type) {
      case 'chart':
        return <ChartWidget data={widget.data} />;
      case 'stats':
        return <StatsWidget data={widget.data} />;
      default:
        return <div>Unknown widget type</div>;
    }
  };

  const addWidget = (type: Widget['type']) => {
    const newWidget: Widget = {
      id: uuidv4(),
      type,
      title: `New ${type} Widget`,
      x: 0,
      y: 0,
      width: 300,
      height: 200,
    };
    setWidgets([...widgets, newWidget]);
  };

  return (
    <div className="p-6">
      <div className="mb-6">
        <h1 className="text-2xl font-bold mb-4">Dashboard</h1>
        <div className="space-x-2">
          <button
            onClick={() => addWidget('stats')}
            className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700"
          >
            Add Stats Widget
          </button>
          <button
            onClick={() => addWidget('chart')}
            className="px-4 py-2 bg-green-600 text-white rounded hover:bg-green-700"
          >
            Add Chart Widget
          </button>
        </div>
      </div>

      <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
        {widgets.map((widget, index) => (
          <DropZone key={widget.id} onDrop={handleDrop} index={index}>
            <DraggableWidget
              widget={widget}
              index={index}
              onMove={moveWidget}
            >
              {renderWidget(widget)}
            </DraggableWidget>
          </DropZone>
        ))}
      </div>
    </div>
  );
}

Step 8: Usage in App

import Dashboard from '@/components/Dashboard';

export default function Home() {
  return (
    <main className="min-h-screen bg-gray-100">
      <Dashboard />
    </main>
  );
}

Summary

React DnD enables sophisticated drag-and-drop interfaces for dashboards. The pattern separates drag logic from UI components, making it maintainable and extensible for complex dashboard requirements.


Share this post on:

Previous Post
Using Framer Motion for Complex React Animations
Next Post
How to Implement React Suspense with Data Fetching