How We Built a Custom Drag and Drop System in React (No Libraries)

Aug 20, 2025

Suraj Choudhury

Drag-and-drop UIs are everywhere—from Trello boards to reorderable lists. But most tutorials rely on third-party libraries that introduce complexity or limit customization. So, we built a custom drag-and-drop system in React from scratch, complete with mouse tracking, drop zones, and ghost cards. This post is not just a usage guide — it’s a deep dive into how each part works, why each handler exists, and how everything ties together.


Design Goals

  • Drag and drop using mouse events

  • No external dependencies

  • Drop indicators and ghost previews

  • Pure React — no refs passed around manually


Component Breakdown

We built two main components:

  • DNDProvider – manages global drag state, listens for global mouse events.

  • DraggableItem – individual draggable item, interacts with the provider and handles visuals.


What Do Event Handlers Do?

In the context of drag-and-drop, event handlers are essential to:

  • Start the drag

  • Track the cursor

  • Show drop indicators

  • Place a ghost card under the mouse

  • Handle where the item was dropped

  • Reset drag state properly

Let’s walk through each handler and understand why it’s used.


DNDProvider: The Drag State Engine

window.addEventListener("mousemove", handleMouseMovement); window.addEventListener("mouseup", handleMouseUp); window.addEventListener("mouseleave", handleMouseLeaveWindow); 



handleMouseMovement

const handleMouseMovement = (event) => { setCurrentMousePosition({ x: event.clientX, y: event.clientY }); };
  • Purpose: Constantly update cursor position

  • Why: So the ghost card can follow the mouse accurately

  • Where used: In DraggableItem, to position the ghost element


handleMouseUp

const handleMouseUp = () => { resetDragState(); }; 
  • Purpose: End drag operation globally

  • Why: If the user drops outside a valid zone, this prevents stale drag state


handleMouseLeaveWindow

const handleMouseLeaveWindow = () => { resetDragState(); };
  • Purpose: Cancel drag if mouse leaves window

  • Why: Prevents stuck drag state if user drags outside the browser window



DraggableItem: The Draggable Unit

This component is responsible for interacting with the user — handling what happens when the item is clicked, moved, hovered over, or dropped.



handleDragStart

const handleDragStart = (event) => { const rect = draggableElementRef.current.getBoundingClientRect(); setInitialMouseOffset({ x: event.clientX - rect.left, y: event.clientY - rect.top, }); setDraggedItem({ draggableId, index }); };
  • Purpose: Initiate dragging

  • Why:Stores which item is being draggedCaptures cursor offset (to position the ghost card correctly)



handleMouseEnterDropZone

const handleMouseEnterDropZone = (event) => { 
  const rect = event.target.getBoundingClientRect(); 
  const dropPosition = event.clientY < rect.top + rect.height / 2 ? "before" : "after"; 
setHoveredDropZonePosition(dropPosition); 
};
  • Purpose: Indicate where the item would land — before or after this item

  • Why:Visual feedback is key for usabilityHelps calculate the drop index correctly



handleMouseLeaveDropZone

const handleMouseLeaveDropZone = () => { 
 setHoveredDropZonePosition(null); 
};
  • Purpose: Remove visual drop indicators

  • Why: Keeps UI clean and avoids confusion when no longer hovering



handleMouseUp (in DragItem)

const handleMouseUp = (event) => { 
  if (!draggedItem || !hoveredDropZonePosition) return; 

handleDragOperationEnd({ draggableId, index: calculateDropTargetIndex() }); 
};

Purpose: Finalize drop

  • Why:

    • Determines where to move the item (based on hoveredDropZonePosition)

    • Communicates the result to the parent via onDragEnd



Ghost Card: Floating Preview

{isCurrentlyBeingDragged && (
  <div
    style={{
      position: "fixed",
      top: currentMousePosition.y - initialMouseOffset.y,
      left: currentMousePosition.x - initialMouseOffset.x,
      width: draggableElementRef?.current?.offsetWidth || "auto",
      pointerEvents: "none",
    }}
  >
    {children}
  </div>
)}
  • Purpose: Show a visual copy of the dragged item under the cursor

  • Why: Improves UX by confirming what’s being dragged

  • How: Uses the global mouse coordinates + local offset to match the original item position


Calculating Drop Target Index

const calculateDropTargetIndex = () => {
    const sourceIndex = draggedItem.index;
    const targetIndex = index;
    if (hoveredDropZonePosition === "before") {
      if (sourceIndex < targetIndex) {
        return targetIndex - 1;
      }
      return targetIndex;
    } else {
      if (sourceIndex < targetIndex) {
        return targetIndex;
      }
      return targetIndex + 1;
    }
};
  • Purpose: Determine the final index where the dragged item should be inserted

  • Why this logic?

    • If you're dragging an item downward and dropping it before another, the actual insertion point is one position above the visible target to account for index shift during removal.

    • If you're dragging upward, no adjustment is needed when dropping before.

    • For after positioning, the logic is reversed — inserting below the hovered item when moving downward, or adjusting downward if dragging upward to avoid jumping over the current target.

This ensures the drag-and-drop reorder behavior feels intuitive no matter the direction.


Final Flow Summary

  1. User clicks item → handleDragStart

  2. Mouse moves → ghost card follows via handleMouseMovement

  3. User hovers over another item → handleMouseEnterDropZone activates

  4. User releases mouse → handleMouseUp triggers onDragEnd

  5. List is reordered, UI re-renders


Bonus: How You Can Extend This

  • Keyboard accessibility

  • 🔄 Horizontal drag (like columns)

  • 🎨 Animated reorder transitions

  • 🪄 Multi-select drag support


Why This Works Well

This approach:

  • Keeps global state minimal and predictable

  • Ensures visual accuracy with mouse tracking

  • Provides full control over styling and UX

  • Makes your app lighter by avoiding heavy libraries


🔗 Live Demo on CodeSandbox

You can try the full working version of this drag-and-drop system here:

👉 Open CodeSandbox Demo

Contact Us

Contact Us

Contact Us

Connect with Us

Get in Touch with Us

Get in Touch with Us

Let us transform your ideas into impactful solutions by combining AI, design, and technology

Work with us

Bring Ideas to Life with Pentagon Studio

Bring Ideas to Life with Pentagon Studio

We are a team of passionate developers, designers, and researchers dedicated to building the future together.

We are a team of passionate developers, designers, and researchers dedicated to building the future together.

98%

Client satisfaction in project outcomes

20+ Projects done

Including web design, app development, and Branding

10+ Clients

Across industries and regions

10+

We are a small team of passionate developers, designers, and researchers dedicated to building the future together.

We are a small team of passionate developers, designers, and researchers dedicated to building the future together.

We are a small team of passionate developers, designers, and researchers dedicated to building the future together.

We are a small team of passionate developers, designers, and researchers dedicated to building the future together.

Made with ❤️ All rights reserved by Pentagon Studio.

Made with ❤️ All rights reserved by Pentagon Studio.

Made with ❤️ All rights reserved by Pentagon Studio.

Made with ❤️ All rights reserved by Pentagon Studio.