Back to Blog

Drag-and-Drop in React with @dnd-kit: Sortable Lists, Kanban, and Accessibility

Build accessible drag-and-drop interfaces with @dnd-kit: sortable lists, kanban boards, multi-container dragging, keyboard navigation, and custom drag overlays in React.

Viprasol Tech Team
December 24, 2026
14 min read

Drag-and-Drop in React with @dnd-kit: Sortable Lists, Kanban, and Accessibility

@dnd-kit replaced react-beautiful-dnd as the go-to drag-and-drop library for React because it handles the one thing rbd never did well: accessibility. Every drag interaction in @dnd-kit works with keyboard navigation out of the box, announces state changes to screen readers, and passes WCAG 2.1 AA without custom code.

This post builds three things: a simple sortable list, a multi-column kanban board with cross-column dragging, and a file upload zone with drag indicatorsβ€”all with real TypeScript, state persistence, and the performance patterns that matter at scale.


Installation

npm install @dnd-kit/core @dnd-kit/sortable @dnd-kit/utilities @dnd-kit/modifiers

The package is modular: @dnd-kit/core is the foundation; @dnd-kit/sortable adds the sortable abstractions; @dnd-kit/modifiers provides constraint helpers (lock to axis, restrict to container).


Part 1: Sortable List

The most common use caseβ€”a reorderable list of items.

// components/SortableList/SortableList.tsx
"use client";

import {
  DndContext,
  closestCenter,
  KeyboardSensor,
  PointerSensor,
  useSensor,
  useSensors,
  DragEndEvent,
  DragStartEvent,
  DragOverlay,
} from "@dnd-kit/core";
import {
  arrayMove,
  SortableContext,
  sortableKeyboardCoordinates,
  verticalListSortingStrategy,
  useSortable,
} from "@dnd-kit/sortable";
import { restrictToVerticalAxis, restrictToParentElement } from "@dnd-kit/modifiers";
import { CSS } from "@dnd-kit/utilities";
import { useState } from "react";
import { GripVertical } from "lucide-react";

interface Item {
  id: string;
  title: string;
  description?: string;
}

interface SortableItemProps {
  item: Item;
  isDragging?: boolean;
}

function SortableItem({ item, isDragging = false }: SortableItemProps) {
  const {
    attributes,
    listeners,
    setNodeRef,
    transform,
    transition,
    isDragging: isSortableDragging,
  } = useSortable({ id: item.id });

  const style = {
    transform: CSS.Transform.toString(transform),
    transition,
    opacity: isSortableDragging ? 0.4 : 1,
  };

  return (
    <div
      ref={setNodeRef}
      style={style}
      className={`
        flex items-center gap-3 rounded-lg border bg-white p-4 shadow-sm
        ${isDragging ? "shadow-lg ring-2 ring-blue-500" : ""}
      `}
    >
      {/* Drag handle β€” only the handle triggers drag, not the whole item */}
      <button
        className="cursor-grab touch-none text-gray-400 hover:text-gray-600 active:cursor-grabbing"
        aria-label={`Drag to reorder ${item.title}`}
        {...attributes}
        {...listeners}
      >
        <GripVertical className="h-5 w-5" />
      </button>
      <div className="flex-1 min-w-0">
        <p className="font-medium truncate">{item.title}</p>
        {item.description && (
          <p className="text-sm text-gray-500 truncate">{item.description}</p>
        )}
      </div>
    </div>
  );
}

interface SortableListProps {
  initialItems: Item[];
  onReorder?: (items: Item[]) => void;
}

export function SortableList({ initialItems, onReorder }: SortableListProps) {
  const [items, setItems] = useState(initialItems);
  const [activeItem, setActiveItem] = useState<Item | null>(null);

  const sensors = useSensors(
    useSensor(PointerSensor, {
      // Require 8px of movement before drag starts (avoids accidental drags)
      activationConstraint: { distance: 8 },
    }),
    useSensor(KeyboardSensor, {
      coordinateGetter: sortableKeyboardCoordinates,
    })
  );

  function handleDragStart(event: DragStartEvent) {
    const item = items.find((i) => i.id === event.active.id);
    setActiveItem(item ?? null);
  }

  function handleDragEnd(event: DragEndEvent) {
    const { active, over } = event;
    setActiveItem(null);

    if (!over || active.id === over.id) return;

    setItems((prev) => {
      const oldIndex = prev.findIndex((i) => i.id === active.id);
      const newIndex = prev.findIndex((i) => i.id === over.id);
      const reordered = arrayMove(prev, oldIndex, newIndex);
      onReorder?.(reordered);
      return reordered;
    });
  }

  return (
    <DndContext
      sensors={sensors}
      collisionDetection={closestCenter}
      onDragStart={handleDragStart}
      onDragEnd={handleDragEnd}
      modifiers={[restrictToVerticalAxis, restrictToParentElement]}
    >
      <SortableContext
        items={items.map((i) => i.id)}
        strategy={verticalListSortingStrategy}
      >
        <div className="space-y-2">
          {items.map((item) => (
            <SortableItem key={item.id} item={item} />
          ))}
        </div>
      </SortableContext>

      {/* DragOverlay renders the item being dragged at cursor position */}
      <DragOverlay>
        {activeItem ? (
          <SortableItem item={activeItem} isDragging />
        ) : null}
      </DragOverlay>
    </DndContext>
  );
}

Persisting order to the server:

// hooks/useSortableList.ts
import { useState, useCallback, useRef } from "react";
import { arrayMove } from "@dnd-kit/sortable";
import { useMutation } from "@tanstack/react-query";
import { DragEndEvent } from "@dnd-kit/core";

interface Orderable {
  id: string;
  order: number;
}

export function useSortableList<T extends Orderable>(
  initialItems: T[],
  persistUrl: string
) {
  const [items, setItems] = useState(
    [...initialItems].sort((a, b) => a.order - b.order)
  );
  const debounceRef = useRef<ReturnType<typeof setTimeout>>();

  const persistMutation = useMutation({
    mutationFn: async (orderedIds: string[]) => {
      const res = await fetch(persistUrl, {
        method: "PATCH",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ orderedIds }),
      });
      if (!res.ok) throw new Error("Failed to persist order");
    },
    onError: (err, _vars, context: { previousItems: T[] } | undefined) => {
      // Rollback optimistic update
      if (context) setItems(context.previousItems);
      console.error("Order persist failed:", err);
    },
  });

  const handleDragEnd = useCallback(
    (event: DragEndEvent) => {
      const { active, over } = event;
      if (!over || active.id === over.id) return;

      setItems((prev) => {
        const oldIndex = prev.findIndex((i) => i.id === active.id);
        const newIndex = prev.findIndex((i) => i.id === over.id);
        const reordered = arrayMove(prev, oldIndex, newIndex);

        // Debounce server update (wait 500ms for rapid reorders)
        clearTimeout(debounceRef.current);
        debounceRef.current = setTimeout(() => {
          persistMutation.mutate(reordered.map((i) => i.id));
        }, 500);

        return reordered;
      });
    },
    [persistMutation]
  );

  return { items, handleDragEnd, isPersisting: persistMutation.isPending };
}

🌐 Looking for a Dev Team That Actually Delivers?

Most agencies sell you a project manager and assign juniors. Viprasol is different β€” senior engineers only, direct Slack access, and a 5.0β˜… Upwork record across 100+ projects.

  • React, Next.js, Node.js, TypeScript β€” production-grade stack
  • Fixed-price contracts β€” no surprise invoices
  • Full source code ownership from day one
  • 90-day post-launch support included

Part 2: Kanban Board with Cross-Column Dragging

// types/kanban.ts
export interface KanbanCard {
  id: string;
  title: string;
  description?: string;
  assignee?: string;
  priority: "low" | "medium" | "high" | "urgent";
  labels: string[];
}

export interface KanbanColumn {
  id: string;
  title: string;
  color: string;
  cardIds: string[];
  limit?: number; // WIP limit
}

export interface KanbanState {
  columns: Record<string, KanbanColumn>;
  cards: Record<string, KanbanCard>;
  columnOrder: string[];
}
// components/KanbanBoard/KanbanBoard.tsx
"use client";

import {
  DndContext,
  DragEndEvent,
  DragOverEvent,
  DragStartEvent,
  DragOverlay,
  PointerSensor,
  KeyboardSensor,
  useSensor,
  useSensors,
  closestCorners,
} from "@dnd-kit/core";
import {
  SortableContext,
  arrayMove,
  sortableKeyboardCoordinates,
  verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import { useState, useCallback } from "react";
import { KanbanState, KanbanCard, KanbanColumn } from "@/types/kanban";
import { KanbanColumnComponent } from "./KanbanColumn";
import { KanbanCardComponent } from "./KanbanCard";

interface Props {
  initialState: KanbanState;
  onStateChange?: (state: KanbanState) => void;
}

export function KanbanBoard({ initialState, onStateChange }: Props) {
  const [state, setState] = useState(initialState);
  const [activeCard, setActiveCard] = useState<KanbanCard | null>(null);
  const [activeColumn, setActiveColumn] = useState<KanbanColumn | null>(null);

  const sensors = useSensors(
    useSensor(PointerSensor, {
      activationConstraint: { distance: 5 },
    }),
    useSensor(KeyboardSensor, {
      coordinateGetter: sortableKeyboardCoordinates,
    })
  );

  // Find which column a card belongs to
  const findColumnForCard = useCallback(
    (cardId: string): string | null => {
      for (const [colId, col] of Object.entries(state.columns)) {
        if (col.cardIds.includes(cardId)) return colId;
      }
      return null;
    },
    [state.columns]
  );

  function handleDragStart(event: DragStartEvent) {
    const { active } = event;

    if (active.data.current?.type === "Card") {
      setActiveCard(state.cards[active.id as string] ?? null);
    } else if (active.data.current?.type === "Column") {
      setActiveColumn(state.columns[active.id as string] ?? null);
    }
  }

  function handleDragOver(event: DragOverEvent) {
    const { active, over } = event;
    if (!over) return;

    const activeId = active.id as string;
    const overId = over.id as string;

    // Only handle card-over-column or card-over-card
    if (active.data.current?.type !== "Card") return;

    const activeColumnId = findColumnForCard(activeId);
    let overColumnId: string | null = null;

    if (over.data.current?.type === "Column") {
      overColumnId = overId;
    } else if (over.data.current?.type === "Card") {
      overColumnId = findColumnForCard(overId);
    }

    if (!activeColumnId || !overColumnId || activeColumnId === overColumnId) return;

    // Cross-column move (DragOver fires frequently β€” use functional update)
    setState((prev) => {
      const activeColCards = [...prev.columns[activeColumnId].cardIds];
      const overColCards = [...prev.columns[overColumnId!].cardIds];

      const activeIndex = activeColCards.indexOf(activeId);
      activeColCards.splice(activeIndex, 1);

      const overIndex = over.data.current?.type === "Card"
        ? overColCards.indexOf(overId)
        : overColCards.length;

      overColCards.splice(Math.max(0, overIndex), 0, activeId);

      return {
        ...prev,
        columns: {
          ...prev.columns,
          [activeColumnId]: {
            ...prev.columns[activeColumnId],
            cardIds: activeColCards,
          },
          [overColumnId!]: {
            ...prev.columns[overColumnId!],
            cardIds: overColCards,
          },
        },
      };
    });
  }

  function handleDragEnd(event: DragEndEvent) {
    const { active, over } = event;
    setActiveCard(null);
    setActiveColumn(null);

    if (!over) return;

    const activeId = active.id as string;
    const overId = over.id as string;

    if (activeId === overId) return;

    // Column reorder
    if (active.data.current?.type === "Column") {
      setState((prev) => {
        const oldIndex = prev.columnOrder.indexOf(activeId);
        const newIndex = prev.columnOrder.indexOf(overId);
        const newOrder = arrayMove(prev.columnOrder, oldIndex, newIndex);
        const newState = { ...prev, columnOrder: newOrder };
        onStateChange?.(newState);
        return newState;
      });
      return;
    }

    // Same-column card reorder
    const columnId = findColumnForCard(activeId);
    if (!columnId) return;

    setState((prev) => {
      const col = prev.columns[columnId];
      const oldIndex = col.cardIds.indexOf(activeId);
      const newIndex = col.cardIds.indexOf(overId);

      if (oldIndex === newIndex) return prev;

      const newCardIds = arrayMove(col.cardIds, oldIndex, newIndex);
      const newState = {
        ...prev,
        columns: {
          ...prev.columns,
          [columnId]: { ...col, cardIds: newCardIds },
        },
      };
      onStateChange?.(newState);
      return newState;
    });
  }

  return (
    <DndContext
      sensors={sensors}
      collisionDetection={closestCorners}
      onDragStart={handleDragStart}
      onDragOver={handleDragOver}
      onDragEnd={handleDragEnd}
    >
      <div className="flex gap-4 overflow-x-auto pb-4 min-h-screen items-start">
        <SortableContext
          items={state.columnOrder}
          strategy={verticalListSortingStrategy}
        >
          {state.columnOrder.map((colId) => {
            const column = state.columns[colId];
            const cards = column.cardIds.map((id) => state.cards[id]).filter(Boolean);

            return (
              <KanbanColumnComponent
                key={colId}
                column={column}
                cards={cards}
              />
            );
          })}
        </SortableContext>
      </div>

      {/* Drag overlay: renders card/column at cursor during drag */}
      <DragOverlay>
        {activeCard ? (
          <KanbanCardComponent card={activeCard} isDragging />
        ) : activeColumn ? (
          <div className="w-72 opacity-80 rotate-3 bg-gray-100 rounded-xl p-4 shadow-2xl">
            <h3 className="font-semibold">{activeColumn.title}</h3>
          </div>
        ) : null}
      </DragOverlay>
    </DndContext>
  );
}
// components/KanbanBoard/KanbanColumn.tsx
"use client";

import { useSortable, SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable";
import { useDroppable } from "@dnd-kit/core";
import { CSS } from "@dnd-kit/utilities";
import { KanbanCard, KanbanColumn } from "@/types/kanban";
import { KanbanCardComponent } from "./KanbanCard";

interface Props {
  column: KanbanColumn;
  cards: KanbanCard[];
}

export function KanbanColumnComponent({ column, cards }: Props) {
  const {
    attributes,
    listeners,
    setNodeRef: setSortableRef,
    transform,
    transition,
    isDragging,
  } = useSortable({
    id: column.id,
    data: { type: "Column", column },
  });

  // Droppable for cards being dragged into this column
  const { setNodeRef: setDroppableRef, isOver } = useDroppable({
    id: column.id,
    data: { type: "Column" },
  });

  const style = {
    transform: CSS.Transform.toString(transform),
    transition,
    opacity: isDragging ? 0.5 : 1,
  };

  const isAtLimit = column.limit !== undefined && cards.length >= column.limit;

  return (
    <div
      ref={setSortableRef}
      style={style}
      className="flex-shrink-0 w-72"
    >
      <div
        className={`
          rounded-xl border-2 bg-gray-50 transition-colors
          ${isOver && !isAtLimit ? "border-blue-400 bg-blue-50" : "border-transparent"}
          ${isDragging ? "shadow-2xl" : ""}
        `}
      >
        {/* Column header β€” drag handle */}
        <div
          className="flex items-center justify-between p-4 cursor-grab active:cursor-grabbing"
          {...attributes}
          {...listeners}
        >
          <div className="flex items-center gap-2">
            <div
              className="h-3 w-3 rounded-full"
              style={{ backgroundColor: column.color }}
            />
            <h3 className="font-semibold text-gray-900">{column.title}</h3>
            <span className="text-sm text-gray-500 bg-gray-200 px-2 py-0.5 rounded-full">
              {cards.length}
              {column.limit ? `/${column.limit}` : ""}
            </span>
          </div>
          {isAtLimit && (
            <span className="text-xs text-amber-600 font-medium">WIP limit</span>
          )}
        </div>

        {/* Cards */}
        <div ref={setDroppableRef} className="px-2 pb-2 min-h-[100px]">
          <SortableContext
            items={cards.map((c) => c.id)}
            strategy={verticalListSortingStrategy}
          >
            <div className="space-y-2">
              {cards.map((card) => (
                <KanbanCardComponent key={card.id} card={card} />
              ))}
            </div>
          </SortableContext>

          {cards.length === 0 && (
            <div className="flex items-center justify-center h-16 text-sm text-gray-400 border-2 border-dashed border-gray-200 rounded-lg">
              Drop cards here
            </div>
          )}
        </div>
      </div>
    </div>
  );
}
// components/KanbanBoard/KanbanCard.tsx
"use client";

import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { KanbanCard } from "@/types/kanban";

const priorityColors = {
  low: "border-l-gray-300",
  medium: "border-l-blue-400",
  high: "border-l-orange-400",
  urgent: "border-l-red-500",
};

interface Props {
  card: KanbanCard;
  isDragging?: boolean;
}

export function KanbanCardComponent({ card, isDragging = false }: Props) {
  const {
    attributes,
    listeners,
    setNodeRef,
    transform,
    transition,
    isDragging: isSortableDragging,
  } = useSortable({
    id: card.id,
    data: { type: "Card", card },
  });

  const style = {
    transform: CSS.Transform.toString(transform),
    transition,
  };

  return (
    <div
      ref={setNodeRef}
      style={style}
      className={`
        rounded-lg border bg-white p-3 shadow-sm cursor-grab active:cursor-grabbing
        border-l-4 ${priorityColors[card.priority]}
        ${isSortableDragging ? "opacity-0" : "opacity-100"}
        ${isDragging ? "shadow-xl rotate-2" : "hover:shadow-md"}
        transition-shadow
      `}
      {...attributes}
      {...listeners}
    >
      <p className="text-sm font-medium text-gray-900">{card.title}</p>
      {card.description && (
        <p className="mt-1 text-xs text-gray-500 line-clamp-2">{card.description}</p>
      )}
      {card.labels.length > 0 && (
        <div className="mt-2 flex flex-wrap gap-1">
          {card.labels.map((label) => (
            <span
              key={label}
              className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-blue-100 text-blue-800"
            >
              {label}
            </span>
          ))}
        </div>
      )}
      {card.assignee && (
        <div className="mt-2 flex items-center gap-1">
          <div className="h-5 w-5 rounded-full bg-gray-300 flex items-center justify-center text-xs font-medium">
            {card.assignee[0].toUpperCase()}
          </div>
          <span className="text-xs text-gray-500">{card.assignee}</span>
        </div>
      )}
    </div>
  );
}

Accessibility

@dnd-kit announces drag state to screen readers automatically. To customize:

// accessibility/announcements.ts
import { Announcements } from "@dnd-kit/core";

export function buildAnnouncements(
  getItemName: (id: string) => string,
  getColumnName: (id: string) => string
): Announcements {
  return {
    onDragStart({ active }) {
      return `Picked up card: ${getItemName(active.id as string)}. Use arrow keys to move.`;
    },
    onDragOver({ active, over }) {
      if (!over) return;
      return `Card ${getItemName(active.id as string)} is over ${
        over.data.current?.type === "Column"
          ? `column ${getColumnName(over.id as string)}`
          : `card ${getItemName(over.id as string)}`
      }`;
    },
    onDragEnd({ active, over }) {
      if (!over) return `Card ${getItemName(active.id as string)} dropped β€” no valid target.`;
      return `Card ${getItemName(active.id as string)} moved to ${
        over.data.current?.type === "Column"
          ? `column ${getColumnName(over.id as string)}`
          : `position of ${getItemName(over.id as string)}`
      }`;
    },
    onDragCancel({ active }) {
      return `Drag cancelled. Card ${getItemName(active.id as string)} returned to original position.`;
    },
  };
}

Use in <DndContext accessibility={{ announcements }}>.


πŸš€ Senior Engineers. No Junior Handoffs. Ever.

You get the senior developer, not a project manager who relays your requirements to someone you never meet. Every Viprasol project has a senior lead from kickoff to launch.

  • MVPs in 4–8 weeks, full platforms in 3–5 months
  • Lighthouse 90+ performance scores standard
  • Works across US, UK, AU timezones
  • Free 30-min architecture review, no commitment

Performance: Avoiding Re-renders

The classic mistake is putting all kanban state at the top level, causing every card to re-render on every drag. Fix with React.memo and stable IDs:

// Memoize individual cards β€” only re-render when the card data changes
const KanbanCardMemo = React.memo(KanbanCardComponent, (prev, next) => {
  return prev.card.id === next.card.id &&
    prev.card.title === next.card.title &&
    prev.card.priority === next.card.priority &&
    prev.isDragging === next.isDragging;
});

// Memoize column card lists
const columnCardIds = useMemo(
  () => column.cardIds,
  // Only recalculate when cardIds array reference changes
  [column.cardIds]
);

For boards with 500+ cards, consider splitting state with zustand and subscribing columns only to their own card slice.


Cost and Timeline Estimates

ComponentTimelineCost (USD)
Simple sortable list0.5–1 day$400–$800
Single-column sortable with persist1–2 days$800–$1,500
Multi-column kanban board3–5 days$2,400–$4,000
Kanban with persistence + optimistic updates5–7 days$4,000–$6,000
Full board (keyboard, screen reader, undo)2 weeks$8,000–$14,000

See Also


Working With Viprasol

We build complex interactive UIs for SaaS productsβ€”project management tools, workflow builders, and data pipeline editors. Our frontend engineers have shipped @dnd-kit-based interfaces for applications handling real-time collaboration across hundreds of concurrent users.

What we deliver:

  • Accessible drag-and-drop with full keyboard navigation
  • Real-time collaborative boards with conflict resolution
  • Optimistic updates and offline-first persistence
  • Performance optimization for large boards (500+ items)
  • Mobile touch support with gesture customization

Explore our web development services or contact us to discuss your interactive UI requirements.

Share this article:

About the Author

V

Viprasol Tech Team

Custom Software Development Specialists

The Viprasol Tech team specialises in algorithmic trading software, AI agent systems, and SaaS development. With 100+ projects delivered across MT4/MT5 EAs, fintech platforms, and production AI systems, the team brings deep technical experience to every engagement. Based in India, serving clients globally.

MT4/MT5 EA DevelopmentAI Agent SystemsSaaS DevelopmentAlgorithmic Trading

Need a Modern Web Application?

From landing pages to complex SaaS platforms β€” we build it all with Next.js and React.

Free consultation β€’ No commitment β€’ Response within 24 hours

Viprasol Β· Web Development

Need a custom web application built?

We build React and Next.js web applications with Lighthouse β‰₯90 scores, mobile-first design, and full source code ownership. Senior engineers only β€” from architecture through deployment.