JP
HomeBlogProjectsResumeAbout

© 2026 JP. All rights reserved.

Back to blog
tutorialprismanextjsnpmStorybookcssTailwind

Building Solo Kanban: A Developer's Project Board with Next.js and @dxsolo/ui Part 3

AdminMarch 22, 202617 min read
Building Solo Kanban: A Developer's Project Board with Next.js and @dxsolo/ui Part 3

On this page

  • Part 3: Drag and Drop That Actually Works
  • Choosing a Drag-and-Drop Library
  • The Native HTML Drag and Drop API
  • react-beautiful-dnd / @hello-pangea/dnd
  • dnd-kit
  • Pragmatic Drag and Drop
  • Our Choice: Pragmatic Drag and Drop
  • Installation
  • How Pragmatic Drag and Drop Works
  • Making Cards Draggable
  • Run it
  • Making Columns Drop Targets
  • The Board: State Management and the Move Handler
  • The Server Action
  • Run it
  • Card-Level Drop Targets: Reordering Within a Column
  • Updating Column to Pass onMoveIssue to Cards
  • Run it
  • Handling Edge Cases
  • Preventing Drops During In-Flight Requests
  • Syncing After Server-Side Changes
  • Full File Reference
  • What We Built in Part 3
  • Troubleshooting
  • What is Next

On this page

  • Part 3: Drag and Drop That Actually Works
  • Choosing a Drag-and-Drop Library
  • The Native HTML Drag and Drop API
  • react-beautiful-dnd / @hello-pangea/dnd
  • dnd-kit
  • Pragmatic Drag and Drop
  • Our Choice: Pragmatic Drag and Drop
  • Installation
  • How Pragmatic Drag and Drop Works
  • Making Cards Draggable
  • Run it
  • Making Columns Drop Targets
  • The Board: State Management and the Move Handler
  • The Server Action
  • Run it
  • Card-Level Drop Targets: Reordering Within a Column
  • Updating Column to Pass onMoveIssue to Cards
  • Run it
  • Handling Edge Cases
  • Preventing Drops During In-Flight Requests
  • Syncing After Server-Side Changes
  • Full File Reference
  • What We Built in Part 3
  • Troubleshooting
  • What is Next
PreviousBuilding Solo Kanban: A Developer's Project Board with Next.js and @dxsolo/ui Part 2NextBuilding Solo Kanban: A Developer's Project Board with Next.js and @dxsolo/ui Part 4

Building Solo Kanban: A Developer's Project Board with Next.js and @dxsolo/ui

Part 3: Drag and Drop That Actually Works

Welcome back. In Part 2, we built the board layout: an app shell with a collapsible sidebar, four scrollable columns with CSS Grid, and responsive issue cards with container queries. Everything renders. Nothing moves.

Now we fix that. By the end of this post, you will be able to drag issue cards between columns, reorder cards within a column, and see those changes persist to the database. We will handle optimistic updates so the UI feels instant, and we will deal with the edge cases that make drag and drop genuinely hard.

Series overview:

  1. Project Setup and Data Modeling
  2. The Board Layout: CSS Grid, Container Queries, and Column Architecture
  3. Drag and Drop That Actually Works
  4. The Issue Detail View with Markdown Editing
  5. Projects, Epics, and Filtering
  6. Polish, Persistence, and Publishing

Choosing a Drag-and-Drop Library

Before we write code, let's evaluate the options. There are three serious contenders for drag and drop in React, plus the native browser API.

The Native HTML Drag and Drop API

The browser ships a drag-and-drop API. You can set draggable="true" on elements, listen for dragstart, dragover, and drop events, and move things around. It works, but it has significant drawbacks for a kanban board:

  • No built-in reordering. You get drop events, but figuring out where in a list the user intended to drop requires manual hit detection.
  • Poor mobile support. Touch devices do not fire native drag events. You need a polyfill or a completely separate touch implementation.
  • Accessibility is on you. No keyboard support, no screen reader announcements, no ARIA attributes. You build all of that from scratch.

For a production kanban board, this is too much work. We want a library.

react-beautiful-dnd / @hello-pangea/dnd

react-beautiful-dnd by Atlassian was the gold standard for years. It is now unmaintained. @hello-pangea/dnd is a community fork that added React 18 and 19 compatibility. It works well, has an excellent API, and handles accessibility out of the box.

The downside: it is opinionated about animations and layout. It controls the drag preview, the drop animation, and the placeholder element. If you want custom drag previews or non-standard layouts, you fight the library.

dnd-kit

dnd-kit is modular and flexible. It gives you primitives (useDraggable, useDroppable, useSortable) and lets you compose them however you want. The @dnd-kit/react package targets React 19, but it is still pre-1.0 (version 0.3.x as of this writing). The API has changed between versions, documentation lags behind, and some edge cases with nested scroll containers require workarounds.

Great library with a promising future, but the instability risk is real for a tutorial that people will follow months from now.

Pragmatic Drag and Drop

This is Atlassian's replacement for react-beautiful-dnd. It is framework-agnostic, uses the native browser drag-and-drop API under the hood, and provides a small, composable API. The key packages:

  • @atlaskit/pragmatic-drag-and-drop — core draggable and drop target primitives
  • @atlaskit/pragmatic-drag-and-drop-hitbox — edge detection for reordering
  • @atlaskit/pragmatic-drag-and-drop-react-drop-indicator — visual drop indicators

It is stable (1.x), actively maintained by Atlassian (they use it in Jira and Confluence), works with any view layer, and handles the native DnD quirks for you. It does not control your rendering — you decide what the drag preview looks like, how the drop indicator renders, and what animations to use.

The trade-off: it is lower-level than @hello-pangea/dnd. You write more code. But you have full control.

Our Choice: Pragmatic Drag and Drop

We are going with Pragmatic Drag and Drop. Here is why:

  1. Stable and maintained. Atlassian uses it in production across their products.
  2. Framework-agnostic core. The drag logic does not depend on React internals, which means fewer compatibility issues across React versions.
  3. Full control over rendering. We already have our card and column components from Part 2. We do not want a library that takes over our layout.
  4. Educational value. Using a lower-level library means we will understand every piece of the drag-and-drop interaction, not just configure a high-level API.

Installation

Install the packages we need:

bash
npm install @atlaskit/pragmatic-drag-and-drop @atlaskit/pragmatic-drag-and-drop-hitbox

Two packages:

  • **@atlaskit/pragmatic-drag-and-drop** — the core library. Provides draggable, dropTargetForElements, and monitorForElements.
  • **@atlaskit/pragmatic-drag-and-drop-hitbox** — provides attachClosestEdge and extractClosestEdge, which we need to know whether a card was dropped above or below another card.

We are not installing the drop indicator package (@atlaskit/pragmatic-drag-and-drop-react-drop-indicator). It pulls in Atlassian's styling dependencies which we do not want. We will build a simple Tailwind-based indicator ourselves.


How Pragmatic Drag and Drop Works

Before we wire it up, let's understand the mental model. Pragmatic DnD has three core concepts:

  1. Draggable. An element the user can pick up. You call draggable({ element, getInitialData }) and the library attaches the native drag listeners.
  2. Drop target. An element that can receive a drop. You call dropTargetForElements({ element, getData, onDragEnter, onDrop }) and the library handles dragover/drop events.
  3. Monitor. A global listener for drag events across the entire page. You call monitorForElements({ onDrop }) to react to any drop, regardless of which specific drop target received it.

The flow:

  1. User starts dragging a card → draggable fires onDragStart
  2. User drags over a column or another card → dropTargetForElements fires onDragEnter, onDrag
  3. User drops → the drop target fires onDrop, and the monitor fires onDrop
  4. You read the data from the drag source and drop target, compute the new order, and update state

Everything is imperative. You call these functions inside useEffect, passing DOM refs. The library attaches and cleans up event listeners for you.


Making Cards Draggable

Let's start with the simplest step: making issue cards draggable. We will not handle drops yet — just make the cards pick-uppable.

Update src/components/board/IssueCard.tsx:

tsx
"use client";
 
import { useRef, useEffect, useState } from "react";
import { Card, CardContent } from "@dxsolo/ui";
import type { IssueWithRelations } from "@/types";
import { IssuePriority } from "@prisma/client";
import { draggable } from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
 
interface IssueCardProps {
  issue: IssueWithRelations;
}
 
const priorityConfig: Record<
  IssuePriority,
  { label: string; className: string }
> = {
  URGENT: { label: "Urgent", className: "bg-red-100 text-red-700" },
  HIGH: { label: "High", className: "bg-orange-100 text-orange-700" },
  MEDIUM: { label: "Med", className: "bg-yellow-100 text-yellow-700" },
  LOW: { label: "Low", className: "bg-gray-100 text-gray-500" },
};
 
export function IssueCard({ issue }: IssueCardProps) {
  const ref = useRef<HTMLDivElement>(null);
  const [isDragging, setIsDragging] = useState(false);
  const priority = priorityConfig[issue.priority];
  const completedSubtasks = issue.subtasks.filter((s) => s.completed).length;
  const totalSubtasks = issue.subtasks.length;
 
  useEffect(() => {
    const el = ref.current;
    if (!el) return;
 
    return draggable({
      element: el,
      getInitialData: () => ({
        type: "card",
        issueId: issue.id,
        status: issue.status,
        order: issue.order,
      }),
      onDragStart: () => setIsDragging(true),
      onDrop: () => setIsDragging(false),
    });
  }, [issue.id, issue.status, issue.order]);
 
  return (
    <div ref={ref}>
      <Card
        className={`cursor-grab hover:ring-2 hover:ring-blue-300 transition-shadow ${
          isDragging ? "opacity-40" : ""
        }`}
      >
        <CardContent className="p-3">
          {/* Title */}
          <h3 className="text-sm font-medium leading-snug">{issue.title}</h3>
 
          {/* Meta row: priority + epic label */}
          <div className="flex items-center gap-2 mt-2 flex-wrap">
            <span
              className={`text-xs font-medium px-1.5 py-0.5 rounded ${priority.className}`}
            >
              {priority.label}
            </span>
 
            {/* Epic label: hidden when container is too narrow */}
            {issue.epic && (
              <span
                className="text-xs px-1.5 py-0.5 rounded hidden @[200px]:inline-block"
                style={{
                  backgroundColor: `${issue.epic.color}20`,
                  color: issue.epic.color,
                }}
              >
                {issue.epic.name}
              </span>
            )}
          </div>
 
          {/* Subtask progress */}
          {totalSubtasks > 0 && (
            <div className="mt-2 flex items-center gap-2">
              <div className="flex-1 h-1 bg-gray-200 rounded-full overflow-hidden">
                <div
                  className="h-full bg-green-500 rounded-full transition-all"
                  style={{
                    width: `${(completedSubtasks / totalSubtasks) * 100}%`,
                  }}
                />
              </div>
              <span className="text-xs text-gray-400">
                {completedSubtasks}/{totalSubtasks}
              </span>
            </div>
          )}
        </CardContent>
      </Card>
    </div>
  );
}

What changed:

  1. A wrapper <div ref={ref}> around the Card. Pragmatic DnD attaches native draggable="true" and event listeners to the element you give it. We use a wrapper div rather than trying to attach to the Card directly, because Card is a @dxsolo/ui component and we do not want to modify it.
  2. **draggable() in a useEffect.** This is the core API. We pass:
  • element: the DOM node to make draggable
  • getInitialData: a function that returns data about what is being dragged. We include the issue ID, current status, and current order. Drop targets will read this data to know what was dropped.
  • onDragStart / onDrop: callbacks to toggle a isDragging state for visual feedback
  1. **isDragging visual feedback.** When a card is being dragged, we reduce its opacity. The browser shows a native drag preview (a screenshot of the element), while the original element fades to indicate where it came from. We also changed cursor-pointer to cursor-grab to hint that cards are draggable.
  2. Cleanup. draggable() returns a cleanup function. Returning it from useEffect ensures listeners are removed when the component unmounts.

Run it

Visit http://localhost:3000 (or let the dev server hot-reload). Try picking up a card. You should see:

  • The cursor changes to a grab hand when hovering over cards
  • Clicking and dragging a card shows the browser's native drag preview (a translucent copy of the card)
  • The original card fades to 40% opacity while being dragged
  • Releasing the card snaps it back to its original position (we have not set up drop targets yet)

If the cards are not draggable, check that the ref is attached to the wrapper div and that the useEffect dependencies include issue.id.


Making Columns Drop Targets

Now we make columns accept drops. When a card is dragged over a column, that column should visually indicate it is a valid drop zone. When the card is dropped, we need to know which column received it.

Update src/components/board/Column.tsx:

tsx
"use client";
 
import { useRef, useEffect, useState } from "react";
import { IssueStatus } from "@prisma/client";
import { IssueCard } from "./IssueCard";
import type { IssueWithRelations } from "@/types";
import { dropTargetForElements } from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
 
interface ColumnProps {
  status: IssueStatus;
  label: string;
  issues: IssueWithRelations[];
  onMoveIssue: (issueId: string, newStatus: IssueStatus, newOrder: number) => void;
}
 
const statusColors: Record<IssueStatus, string> = {
  OPEN: "bg-gray-100 text-gray-700",
  IN_PROGRESS: "bg-blue-100 text-blue-700",
  REVIEW: "bg-amber-100 text-amber-700",
  DONE: "bg-green-100 text-green-700",
};
 
const statusBorderColors: Record<IssueStatus, string> = {
  OPEN: "border-t-gray-400",
  IN_PROGRESS: "border-t-blue-500",
  REVIEW: "border-t-amber-500",
  DONE: "border-t-green-500",
};
 
const emptyMessages: Record<IssueStatus, string> = {
  OPEN: "No open issues. Create one to get started.",
  IN_PROGRESS: "Nothing in progress. Drag an issue here.",
  REVIEW: "Review queue is empty.",
  DONE: "No completed issues yet.",
};
 
export function Column({ status, label, issues, onMoveIssue }: ColumnProps) {
  const ref = useRef<HTMLDivElement>(null);
  const [isDraggedOver, setIsDraggedOver] = useState(false);
 
  useEffect(() => {
    const el = ref.current;
    if (!el) return;
 
    return dropTargetForElements({
      element: el,
      getData: () => ({ type: "column", status }),
      canDrop: ({ source }) => source.data.type === "card",
      onDragEnter: () => setIsDraggedOver(true),
      onDragLeave: () => setIsDraggedOver(false),
      onDrop: ({ source }) => {
        setIsDraggedOver(false);
        const issueId = source.data.issueId as string;
        const sourceStatus = source.data.status as IssueStatus;
 
        // Only handle drops directly on the column (not on a card within the column)
        // Card-level drops are handled by the card drop targets
        if (sourceStatus === status) return;
 
        // Dropping on the column itself puts the card at the end
        const newOrder = issues.length;
        onMoveIssue(issueId, status, newOrder);
      },
    });
  }, [status, issues.length, onMoveIssue]);
 
  return (
    <div
      ref={ref}
      className={`flex flex-col h-full min-h-0 rounded-lg bg-gray-50 border-t-2 ${statusBorderColors[status]} ${
        isDraggedOver ? "ring-2 ring-blue-300 bg-blue-50/50" : ""
      }`}
    >
      {/* Column header: sticky, never scrolls */}
      <div className="flex items-center justify-between px-3 py-2 border-b border-gray-200">
        <div className="flex items-center gap-2">
          <span
            className={`text-xs font-semibold px-2 py-0.5 rounded-full ${statusColors[status]}`}
          >
            {label}
          </span>
          <span className="text-xs text-gray-400">{issues.length}</span>
        </div>
      </div>
 
      {/* Card list: scrollable, container query context */}
      <div className="flex-1 overflow-y-auto p-2 flex flex-col gap-2 @container">
        {issues.map((issue) => (
          <IssueCard key={issue.id} issue={issue} />
        ))}
 
        {issues.length === 0 && (
          <div
            className={`flex items-center justify-center h-32 border-2 border-dashed rounded-lg ${
              isDraggedOver
                ? "border-blue-300 bg-blue-50"
                : "border-gray-200"
            }`}
          >
            <p className="text-xs text-gray-400 text-center px-4">
              {emptyMessages[status]}
            </p>
          </div>
        )}
      </div>
    </div>
  );
}

What changed:

  1. **dropTargetForElements() in a useEffect.** This registers the column as a drop target. We pass:
  • element: the column's root div
  • getData: returns { type: "column", status } so we can identify which column received the drop
  • canDrop: only accept drops from elements with type: "card". This prevents other draggable elements (if we add any later) from dropping into columns
  • onDragEnter / onDragLeave: toggle visual feedback
  • onDrop: compute the new order and call onMoveIssue
  1. **onMoveIssue prop.** The column does not manage issue state — the Board component does. When a drop happens, the column calls onMoveIssue(issueId, newStatus, newOrder) and the board handles the state update.
  2. Visual feedback. When a card is dragged over the column, we add a blue ring and a subtle blue tint to the background. The empty state's dashed border also turns blue, making empty columns clearly signpost that they accept drops.
  3. Drop directly on column = append to end. When a user drops a card on the column background (not on another card), we put it at the end of the list. Card-to-card drops with precise positioning come next.

Notice we are not handling card-level drop targets yet. Right now, dropping a card on a column always places it at the end. In the next section, we will make individual cards into drop targets so you can drop between specific cards.


The Board: State Management and the Move Handler

The Board component needs to manage the issue list as local state so we can update it optimistically when a card is dropped. Right now it receives issues as a prop and groups them — it does not hold any state.

Update src/components/board/Board.tsx:

tsx
"use client";
 
import { useState, useCallback } from "react";
import { IssueStatus } from "@prisma/client";
import { Column } from "./Column";
import type { IssueWithRelations } from "@/types";
import { moveIssue } from "@/actions/move-issue";
 
const COLUMNS: { status: IssueStatus; label: string }[] = [
  { status: "OPEN", label: "Open" },
  { status: "IN_PROGRESS", label: "In Progress" },
  { status: "REVIEW", label: "Review" },
  { status: "DONE", label: "Done" },
];
 
interface BoardProps {
  issues: IssueWithRelations[];
}
 
export function Board({ issues: initialIssues }: BoardProps) {
  const [issues, setIssues] = useState(initialIssues);
 
  const handleMoveIssue = useCallback(
    async (issueId: string, newStatus: IssueStatus, newOrder: number) => {
      // Snapshot for rollback
      const previousIssues = issues;
 
      // Optimistic update
      setIssues((prev) => {
        const issue = prev.find((i) => i.id === issueId);
        if (!issue) return prev;
 
        // Remove the issue from its current position
        const without = prev.filter((i) => i.id !== issueId);
 
        // Get issues in the target column, sorted by order
        const targetColumnIssues = without
          .filter((i) => i.status === newStatus)
          .sort((a, b) => a.order - b.order);
 
        // Clamp the order to valid range
        const clampedOrder = Math.min(newOrder, targetColumnIssues.length);
 
        // Insert at the new position and re-index the target column
        targetColumnIssues.splice(clampedOrder, 0, {
          ...issue,
          status: newStatus,
          order: clampedOrder,
        });
 
        // Re-index all items in the target column
        const reindexed = targetColumnIssues.map((item, index) => ({
          ...item,
          order: index,
        }));
 
        // Rebuild the full issue list
        const otherIssues = without.filter((i) => i.status !== newStatus);
        return [...otherIssues, ...reindexed];
      });
 
      // Persist to database
      try {
        await moveIssue(issueId, newStatus, newOrder);
      } catch (error) {
        console.error("Failed to move issue:", error);
        // Rollback on failure
        setIssues(previousIssues);
      }
    },
    [issues]
  );
 
  const issuesByStatus = COLUMNS.map((col) => ({
    ...col,
    issues: issues
      .filter((issue) => issue.status === col.status)
      .sort((a, b) => a.order - b.order),
  }));
 
  return (
    <div className="h-full p-4 overflow-x-auto">
      <div className="grid grid-cols-4 gap-4 h-full min-w-[800px]">
        {issuesByStatus.map((col) => (
          <Column
            key={col.status}
            status={col.status}
            label={col.label}
            issues={col.issues}
            onMoveIssue={handleMoveIssue}
          />
        ))}
      </div>
    </div>
  );
}

Key changes:

  1. **useState for issues.** We renamed the prop to initialIssues and initialize local state from it. This gives us optimistic control — we can update the UI immediately and roll back if the server rejects the change.
  2. **handleMoveIssue callback.** This is the core of the drag-and-drop logic:
  • Snapshot the current state for rollback
  • Optimistically update the local state: remove the issue from its old position, insert it into the target column at the specified order, and re-index all cards in the target column to maintain contiguous order values
  • Persist by calling a server action (moveIssue). If it fails, roll back to the snapshot.
  1. Sorting by order. The issuesByStatus grouping now explicitly sorts by order within each column. This ensures cards render in the correct order after a drag.
  2. Passing onMoveIssue to Column. Each column receives the same handler.

The Server Action

We need a server action to persist the move to the database. When a card moves, we need to update its status and order, and re-index the other cards in the target column to keep order values contiguous.

Create src/actions/move-issue.ts:

typescript
"use server";
 
import { prisma } from "@/lib/prisma";
import { IssueStatus } from "@prisma/client";
 
export async function moveIssue(
  issueId: string,
  newStatus: IssueStatus,
  newOrder: number
) {
  // Get the issue to find its project
  const issue = await prisma.issue.findUnique({
    where: { id: issueId },
    select: { projectId: true, status: true, order: true },
  });
 
  if (!issue) {
    throw new Error("Issue not found");
  }
 
  const oldStatus = issue.status;
  const oldOrder = issue.order;
 
  // If nothing changed, skip
  if (oldStatus === newStatus && oldOrder === newOrder) {
    return;
  }
 
  // Use a transaction to keep order values consistent
  await prisma.$transaction(async (tx) => {
    // If moving to a different column, close the gap in the old column
    if (oldStatus !== newStatus) {
      await tx.issue.updateMany({
        where: {
          projectId: issue.projectId,
          status: oldStatus,
          order: { gt: oldOrder },
        },
        data: {
          order: { decrement: 1 },
        },
      });
    }
 
    // Make room in the target column at the new position
    if (oldStatus !== newStatus) {
      // Moving to a different column: shift everything at or after newOrder down
      await tx.issue.updateMany({
        where: {
          projectId: issue.projectId,
          status: newStatus,
          order: { gte: newOrder },
        },
        data: {
          order: { increment: 1 },
        },
      });
    } else {
      // Reordering within the same column
      if (newOrder > oldOrder) {
        // Moving down: shift items between old and new position up
        await tx.issue.updateMany({
          where: {
            projectId: issue.projectId,
            status: newStatus,
            order: { gt: oldOrder, lte: newOrder },
            id: { not: issueId },
          },
          data: {
            order: { decrement: 1 },
          },
        });
      } else if (newOrder < oldOrder) {
        // Moving up: shift items between new and old position down
        await tx.issue.updateMany({
          where: {
            projectId: issue.projectId,
            status: newStatus,
            order: { gte: newOrder, lt: oldOrder },
            id: { not: issueId },
          },
          data: {
            order: { increment: 1 },
          },
        });
      }
    }
 
    // Update the moved issue
    await tx.issue.update({
      where: { id: issueId },
      data: {
        status: newStatus,
        order: newOrder,
      },
    });
  });
}

This is the most complex piece of the feature, so let's walk through the logic:

The order problem. Each card has an integer order field. Cards in a column render sorted by this value: 0, 1, 2, 3. When you move card 0 to position 2, you cannot just set its order to 2 — card 2 already exists at that position. You need to shift other cards to make room.

Cross-column moves. When a card moves from In Progress to Done:

  1. Close the gap in In Progress: decrement the order of every card that was below the removed card
  2. Make room in Done: increment the order of every card at or after the insertion point
  3. Set the moved card's status and order

Within-column reorder. When a card moves from position 0 to position 2 within the same column:

  1. Shift cards between positions 1 and 2 up by one (they fill the gap left by the moved card)
  2. Set the card's order to 2

Transaction. All of this happens in a Prisma transaction so the database stays consistent. If any step fails, everything rolls back.

Why integer ordering? You might wonder why we do not use fractional indexing (like 0.5 between 0 and 1). Fractional indexing avoids the need to re-index other items, but the fractions get increasingly long over time, and you eventually need to re-normalize anyway. For a solo dev's board with at most a few dozen issues per column, integer reindexing is fast and simple.

Run it

Visit http://localhost:3000. Try dragging a card from one column to another. You should see:

  • The card fades when you pick it up
  • The target column highlights with a blue ring as you drag over it
  • Dropping the card on a column moves it to the end of that column
  • The card appears instantly in the new column (optimistic update)
  • Refresh the page — the card is still in its new position (persisted to database)
  • If you drop a card on the column it is already in, nothing happens

You cannot yet drop a card between specific cards in a column. That is next.


Card-Level Drop Targets: Reordering Within a Column

Right now, dropping on a column always appends the card to the end. To support reordering — dropping a card between two other cards — we need to make each card a drop target too, and use edge detection to figure out whether the user intended to drop above or below.

This is where @atlaskit/pragmatic-drag-and-drop-hitbox comes in. It provides attachClosestEdge and extractClosestEdge, which calculate whether the cursor is closer to the top or bottom edge of the drop target.

Update src/components/board/IssueCard.tsx to add drop target behavior:

tsx
"use client";
 
import { useRef, useEffect, useState } from "react";
import { Card, CardContent } from "@dxsolo/ui";
import type { IssueWithRelations } from "@/types";
import { IssuePriority, IssueStatus } from "@prisma/client";
import { draggable, dropTargetForElements } from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
import { attachClosestEdge, extractClosestEdge } from "@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge";
import type { Edge } from "@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge";
 
interface IssueCardProps {
  issue: IssueWithRelations;
  onMoveIssue: (issueId: string, newStatus: IssueStatus, newOrder: number) => void;
}
 
const priorityConfig: Record<
  IssuePriority,
  { label: string; className: string }
> = {
  URGENT: { label: "Urgent", className: "bg-red-100 text-red-700" },
  HIGH: { label: "High", className: "bg-orange-100 text-orange-700" },
  MEDIUM: { label: "Med", className: "bg-yellow-100 text-yellow-700" },
  LOW: { label: "Low", className: "bg-gray-100 text-gray-500" },
};
 
export function IssueCard({ issue, onMoveIssue }: IssueCardProps) {
  const ref = useRef<HTMLDivElement>(null);
  const [isDragging, setIsDragging] = useState(false);
  const [closestEdge, setClosestEdge] = useState<Edge | null>(null);
  const priority = priorityConfig[issue.priority];
  const completedSubtasks = issue.subtasks.filter((s) => s.completed).length;
  const totalSubtasks = issue.subtasks.length;
 
  useEffect(() => {
    const el = ref.current;
    if (!el) return;
 
    const cleanupDrag = draggable({
      element: el,
      getInitialData: () => ({
        type: "card",
        issueId: issue.id,
        status: issue.status,
        order: issue.order,
      }),
      onDragStart: () => setIsDragging(true),
      onDrop: () => setIsDragging(false),
    });
 
    const cleanupDrop = dropTargetForElements({
      element: el,
      getData: ({ input, element }) => {
        return attachClosestEdge(
          { type: "card", issueId: issue.id, status: issue.status, order: issue.order },
          { input, element, allowedEdges: ["top", "bottom"] }
        );
      },
      canDrop: ({ source }) => {
        // Don't allow dropping on yourself
        return source.data.type === "card" && source.data.issueId !== issue.id;
      },
      onDragEnter: ({ self }) => {
        setClosestEdge(extractClosestEdge(self.data));
      },
      onDrag: ({ self }) => {
        setClosestEdge(extractClosestEdge(self.data));
      },
      onDragLeave: () => {
        setClosestEdge(null);
      },
      onDrop: ({ self, source }) => {
        setClosestEdge(null);
 
        const edge = extractClosestEdge(self.data);
        const targetOrder = issue.order;
        const sourceId = source.data.issueId as string;
        const sourceStatus = source.data.status as IssueStatus;
 
        // Calculate the new order based on which edge was closest
        let newOrder: number;
        if (edge === "top") {
          newOrder = targetOrder;
        } else {
          newOrder = targetOrder + 1;
        }
 
        // If moving within the same column and the source was above the target,
        // the target's order will shift after removal, so adjust
        if (sourceStatus === issue.status) {
          const sourceOrder = source.data.order as number;
          if (sourceOrder < targetOrder) {
            newOrder = newOrder - 1;
          }
        }
 
        onMoveIssue(sourceId, issue.status, newOrder);
      },
    });
 
    return () => {
      cleanupDrag();
      cleanupDrop();
    };
  }, [issue.id, issue.status, issue.order, onMoveIssue]);
 
  return (
    <div ref={ref} className="relative">
      {/* Drop indicator: top edge */}
      {closestEdge === "top" && (
        <div className="absolute top-0 left-0 right-0 h-0.5 bg-blue-500 -translate-y-1 rounded-full" />
      )}
 
      <Card
        className={`cursor-grab hover:ring-2 hover:ring-blue-300 transition-shadow ${
          isDragging ? "opacity-40" : ""
        }`}
      >
        <CardContent className="p-3">
          {/* Title */}
          <h3 className="text-sm font-medium leading-snug">{issue.title}</h3>
 
          {/* Meta row: priority + epic label */}
          <div className="flex items-center gap-2 mt-2 flex-wrap">
            <span
              className={`text-xs font-medium px-1.5 py-0.5 rounded ${priority.className}`}
            >
              {priority.label}
            </span>
 
            {/* Epic label: hidden when container is too narrow */}
            {issue.epic && (
              <span
                className="text-xs px-1.5 py-0.5 rounded hidden @[200px]:inline-block"
                style={{
                  backgroundColor: `${issue.epic.color}20`,
                  color: issue.epic.color,
                }}
              >
                {issue.epic.name}
              </span>
            )}
          </div>
 
          {/* Subtask progress */}
          {totalSubtasks > 0 && (
            <div className="mt-2 flex items-center gap-2">
              <div className="flex-1 h-1 bg-gray-200 rounded-full overflow-hidden">
                <div
                  className="h-full bg-green-500 rounded-full transition-all"
                  style={{
                    width: `${(completedSubtasks / totalSubtasks) * 100}%`,
                  }}
                />
              </div>
              <span className="text-xs text-gray-400">
                {completedSubtasks}/{totalSubtasks}
              </span>
            </div>
          )}
        </CardContent>
      </Card>
 
      {/* Drop indicator: bottom edge */}
      {closestEdge === "bottom" && (
        <div className="absolute bottom-0 left-0 right-0 h-0.5 bg-blue-500 translate-y-1 rounded-full" />
      )}
    </div>
  );
}

This is a lot of code, but the pieces are straightforward:

Edge detection. When a card is dragged over another card, attachClosestEdge calculates whether the cursor is closer to the top or bottom edge of the target card. We store this in state and render a thin blue line above or below the card to show where the drop will land.

Order calculation. When the user drops:

  • If the cursor was near the top edge, the new order equals the target card's order (insert before it)
  • If the cursor was near the bottom edge, the new order equals the target card's order + 1 (insert after it)

Within-column adjustment. When moving a card within the same column, there is a subtle off-by-one issue. If the card you are dragging was above the target, removing it from the list shifts the target's effective index down by one. We compensate by subtracting 1 from the new order.

Drop indicator. We render a 2px blue line at the closest edge. It is positioned absolutely relative to the card wrapper div. The translate-y-1 nudges it slightly outside the card so it sits in the gap between cards.

**canDrop filter.** We prevent a card from being dropped on itself. Without this check, picking up and releasing a card in the same spot would trigger an unnecessary move.

Updating Column to Pass onMoveIssue to Cards

The Column component now needs to pass onMoveIssue through to each IssueCard:

diff
  {issues.map((issue) => (
-   <IssueCard key={issue.id} issue={issue} />
+   <IssueCard key={issue.id} issue={issue} onMoveIssue={onMoveIssue} />
  ))}

Update the card list mapping in Column.tsx with this one-line change.

Run it

Visit http://localhost:3000. The full drag-and-drop experience should now work:

  • Cross-column moves: drag a card from one column to another. It appears at the end of the target column.
  • Precise positioning: drag a card over another card. A blue line appears above or below indicating where it will land. Drop it and it inserts at that exact position.
  • Within-column reorder: drag a card up or down within the same column. The blue indicator shows the insertion point.
  • Persistence: refresh the page after moving cards. All positions are preserved.
  • Optimistic feel: the UI updates instantly when you drop. There is no loading spinner or delay.

Try these edge cases:

  • Drop a card on an empty column — it becomes the first card
  • Drag the first card below the last card in the same column
  • Drag the last card above the first card in the same column
  • Drop a card back on itself — nothing should happen

Handling Edge Cases

Drag and drop has a lot of edge cases. Let's address the most important ones.

Preventing Drops During In-Flight Requests

If a user drags a card, drops it, and immediately drags it again before the first server action completes, we could end up with inconsistent state. The optimistic update from the first drag has not been confirmed yet, and the second drag is working with potentially incorrect data.

For a solo dev's kanban board, this is unlikely to cause real problems — there is only one user. But let's be defensive. Add a simple lock to the board:

diff
  export function Board({ issues: initialIssues }: BoardProps) {
    const [issues, setIssues] = useState(initialIssues);
+   const [isMoving, setIsMoving] = useState(false);
 
    const handleMoveIssue = useCallback(
      async (issueId: string, newStatus: IssueStatus, newOrder: number) => {
+       if (isMoving) return;
+       setIsMoving(true);
+
        // Snapshot for rollback
        const previousIssues = issues;
 
        // Optimistic update
        setIssues((prev) => {
          // ... same logic as before ...
        });
 
-       try {
+       try {
          await moveIssue(issueId, newStatus, newOrder);
        } catch (error) {
          console.error("Failed to move issue:", error);
          setIssues(previousIssues);
+       } finally {
+         setIsMoving(false);
        }
      },
-     [issues]
+     [issues, isMoving]
    );

The try...finally ensures setIsMoving(false) always runs — even if the server action throws. Without finally, a failed move would permanently lock the board.

Syncing After Server-Side Changes

Our board uses useState(initialIssues) to initialize. If the page is reloaded or the server re-renders, we get fresh data. But what if the server data changes while the board is open (e.g., another browser tab)? For a solo dev app, this is a non-issue. But if you want to be thorough, you can sync by adding a key to the Board component:

tsx
// In page.tsx, force re-mount when data changes:
<Board key={JSON.stringify(project.issues.map(i => i.id + i.status + i.order))} issues={project.issues} />

This forces React to unmount and remount the Board whenever the issue data changes, resetting the local state. It is a blunt instrument but effective for server-rendered pages.


Full File Reference

Here is every file we created or modified in this part:

FilePurpose
src/components/board/Board.tsxState management, optimistic updates, move handler
src/components/board/Column.tsxDrop target for columns, visual feedback
src/components/board/IssueCard.tsxDraggable + drop target with edge detection
src/actions/move-issue.tsServer action for persisting moves with order reindexing

What We Built in Part 3

Here is what we accomplished:

  • Evaluated drag-and-drop library options and chose Pragmatic Drag and Drop for its stability, control, and framework independence
  • Made issue cards draggable with visual feedback (opacity change, grab cursor)
  • Made columns into drop targets with highlight states for valid drop zones
  • Added card-level drop targets with closest-edge detection for precise insertion
  • Built a server action that handles both cross-column moves and within-column reordering using database transactions
  • Implemented optimistic updates with rollback on failure
  • Added a move lock to prevent race conditions from rapid successive drags
  • Rendered drop indicators (thin blue lines) to show exactly where a card will land

Key concepts we covered:

  • Optimistic updates. Update the UI immediately, persist in the background, roll back on failure. This makes drag and drop feel instant.
  • Integer order reindexing. When you move an item in an ordered list, you need to shift other items to maintain contiguous indices. Transactions keep this consistent.
  • Closest edge detection. To know whether the user wants to drop above or below a card, measure the cursor's distance to the top and bottom edges of the drop target.
  • The within-column off-by-one. When dragging a card down within the same column, removing it from the list shifts indices. You need to subtract 1 from the target position to compensate.

Troubleshooting

If something is not working, check these common issues:

  • Cards are not draggable: make sure the ref is on the wrapper <div>, not on the <Card> component. Pragmatic DnD needs a real DOM element.
  • Drops are not registering: check that canDrop returns true for your drag source. The type: "card" must match between getInitialData and canDrop.
  • Order is wrong after a move: check the within-column adjustment. If a card that was at position 0 is dropped at position 2, the adjustment subtracts 1 because removing position 0 shifts everything up.
  • Server action errors: make sure src/actions/move-issue.ts has "use server" at the top and that the prisma import resolves correctly.
  • Cards jump on drop: this usually means the optimistic update logic does not match the server action logic. Both must compute the same final order.

What is Next

In Part 4, we build the issue detail view. Clicking a card will open a panel or modal showing the full issue: title, markdown description with a live editor, subtask list with checkboxes, and metadata editing. We will evaluate markdown editors, handle real-time saving, and add a few components to @dxsolo/ui along the way.

See you there.


Source code for this part: github.com/jpDxsoloOrg/solo-kanban/tree/part-3

Live demo: Coming in Part 6

@dxsolo/ui Storybook: jpdxsoloorg.github.io/jpComponent