Drag and Drop

Intuitive interaction patterns for reordering, organizing, and transferring content through direct manipulation. Essential for productivity tools, builders, and content management interfaces.

Reorderable lists

Allow users to reorganize items by dragging and dropping. Provides immediate visual feedback during the drag operation.

Project tasks

⋮⋮

Research competitors

Analyze top 5 competitors

⋮⋮

Design wireframes

Create low-fidelity mockups

⋮⋮

User testing

Conduct 5 user interviews

⋮⋮

Finalize designs

Prepare handoff documentation

Drag items to reorder (demo only)

File drop zone

Designated areas where users can drag and drop files for upload. Provides clear visual feedback during the drag operation.

Drop files here to upload

or click to browse

Kanban board

Visual task management using columns and draggable cards. Move items between stages to track progress.

To Do (2)

Set up project

Initialize repo and tooling

Research

Gather requirements and constraints

In Progress (1)

Design mockups

Create high-fidelity designs

Done (1)

Kickoff meeting

Align with stakeholders

Use cases

Reorderable lists

Drag items to re-prioritize tasks, sort tables, or organize content

File uploads

Drag and drop files onto designated zones for quick uploads with visual feedback

Kanban boards

Move cards between columns to track workflow state and progress

Builder interfaces

Compose layouts by dragging components onto a canvas or container

Drag handle anatomy

Draggable item contentDrop position indicator🤚cursor-grabDRAG HANDLECONTENT

Interaction states

StateAppearanceNotes
IdleNormal cursor, no visual feedbackDefault state; ready for interaction
HoverBorder highlight, cursor changes to grabIndicates item is draggable on mouseover
DraggingOpacity reduction, cursor changes to grabbingVisual clone follows cursor; original item dims
Drag overDrop zone highlight, dashed borderIndicates valid drop target area
DropSmooth transition to new positionImmediate reordering with animation

Usage guidelines

✓ Do
  • ·Provide clear visual affordances for draggable items (e.g., grip handles)
  • ·Show immediate feedback during drag (ghost image, opacity change)
  • ·Indicate valid drop targets with highlighting
  • ·Support keyboard alternatives for accessibility
✗ Don't
  • ·Don't require drag for essential tasks (provide alternative controls)
  • ·Avoid drag in small click targets (hard to initiate)
  • ·Don't allow dropping in invalid areas without feedback
  • ·Avoid excessive drag distance for simple reordering

Implementation

DragAndDrop.tsx
tsx
"use client";

import { useState, useRef, DragEvent } from "react";
import { cn } from "@/lib";

// ── Reorderable List ───────────────────────────────────────

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

interface DraggableListProps<T extends DraggableItem> {
  items: T[];
  onChange: (items: T[]) => void;
  renderItem: (item: T) => React.ReactNode;
  className?: string;
}

export function DraggableList<T extends DraggableItem>({
  items,
  onChange,
  renderItem,
  className,
}: DraggableListProps<T>) {
  const [draggedIndex, setDraggedIndex] = useState<number | null>(null);

  function handleDragStart(e: DragEvent<HTMLDivElement>, index: number) {
    e.dataTransfer.effectAllowed = "move";
    e.dataTransfer.setData("text/plain", index.toString());
    setDraggedIndex(index);
  }

  function handleDragOver(e: DragEvent<HTMLDivElement>, targetIndex: number) {
    e.preventDefault();
    if (draggedIndex === null || draggedIndex === targetIndex) return;

    const newItems = [...items];
    const [draggedItem] = newItems.splice(draggedIndex, 1);
    newItems.splice(targetIndex, 0, draggedItem);
    onChange(newItems);
    setDraggedIndex(targetIndex);
  }

  function handleDragEnd() {
    setDraggedIndex(null);
  }

  return (
    <div className={cn("space-y-2", className)}>
      {items.map((item, index) => (
        <div
          key={item.id}
          draggable
          onDragStart={(e) => handleDragStart(e, index)}
          onDragOver={(e) => handleDragOver(e, index)}
          onDragEnd={handleDragEnd}
          className={cn(
            "p-4 rounded-lg border cursor-grab active:cursor-grabbing transition-all",
            "bg-[rgb(var(--surface))] border-[rgb(var(--border))]",
            "hover:border-[rgb(var(--accent))] hover:bg-[rgb(var(--surface-raised))]",
            draggedIndex === index && "opacity-50 border-[rgb(var(--accent))]",
          )}
        >
          {renderItem(item)}
        </div>
      ))}
    </div>
  );
}

// ── Drop Zone ──────────────────────────────────────────────

interface DropZoneProps {
  onDrop: (files: File[]) => void;
  children: React.ReactNode;
  className?: string;
  multiple?: boolean;
  accept?: string;
}

export function DropZone({
  onDrop,
  children,
  className,
  multiple = true,
  accept,
}: DropZoneProps) {
  const [isOver, setIsOver] = useState(false);
  const inputRef = useRef<HTMLInputElement>(null);

  function handleDrop(e: DragEvent<HTMLDivElement>) {
    e.preventDefault();
    setIsOver(false);
    const files = Array.from(e.dataTransfer.files);
    if (files.length) onDrop(files);
  }

  function handleDragOver(e: DragEvent<HTMLDivElement>) {
    e.preventDefault();
    setIsOver(true);
  }

  function handleDragLeave() {
    setIsOver(false);
  }

  function handleFileInput(e: React.ChangeEvent<HTMLInputElement>) {
    const files = Array.from(e.target.files || []);
    if (files.length) onDrop(files);
  }

  return (
    <div className="space-y-2">
      <div
        role="button"
        tabIndex={0}
        onClick={() => inputRef.current?.click()}
        onKeyDown={(e) => {
          if (e.key === "Enter" || e.key === " ") {
            e.preventDefault();
            inputRef.current?.click();
          }
        }}
        onDrop={handleDrop}
        onDragOver={handleDragOver}
        onDragLeave={handleDragLeave}
        className={cn(
          "p-8 rounded-xl border-2 border-dashed transition-all text-center cursor-pointer",
          "bg-[rgb(var(--surface))] border-[rgb(var(--border))]",
          isOver
            ? "border-[rgb(var(--accent))] bg-[rgb(var(--accent-subtle))]"
            : "hover:border-[rgb(var(--accent))] hover:bg-[rgb(var(--surface-raised))]",
          className
        )}
        aria-label="Drop files here or click to browse"
      >
        {children}
      </div>
      <input
        ref={inputRef}
        type="file"
        multiple={multiple}
        accept={accept}
        onChange={handleFileInput}
        className="sr-only"
      />
    </div>
  );
}

// ── Kanban Board ──────────────────────────────────────────

interface Column<T> {
  id: string;
  title: string;
  items: T[];
}

interface KanbanBoardProps<T extends { id: string }> {
  columns: Column<T>[];
  onChange: (columns: Column<T>[]) => void;
  renderItem: (item: T) => React.ReactNode;
}

export function KanbanBoard<T extends { id: string }>({
  columns,
  onChange,
  renderItem,
}: KanbanBoardProps<T>) {
  const [draggedItem, setDraggedItem] = useState<{ item: T; fromCol: string } | null>(null);

  function handleDragStart(item: T, fromCol: string) {
    setDraggedItem({ item, fromCol });
  }

  function handleDrop(toColId: string) {
    if (!draggedItem) return;

    const newColumns = columns.map((col) => {
      if (col.id === draggedItem.fromCol) {
        return {
          ...col,
          items: col.items.filter((i) => i.id !== draggedItem.item.id),
        };
      }
      if (col.id === toColId) {
        return {
          ...col,
          items: [...col.items, draggedItem.item],
        };
      }
      return col;
    });

    onChange(newColumns);
    setDraggedItem(null);
  }

  function handleDragOver(e: DragEvent<HTMLDivElement>) {
    e.preventDefault();
  }

  return (
    <div className="flex gap-4 overflow-x-auto pb-4">
      {columns.map((col) => (
        <div
          key={col.id}
          className="w-72 flex-shrink-0 rounded-xl border bg-[rgb(var(--surface-raised))]"
          onDragOver={handleDragOver}
          onDrop={() => handleDrop(col.id)}
        >
          <div className="px-4 py-3 border-b border-[rgb(var(--border))]">
            <h3 className="text-[12px] font-semibold uppercase tracking-wider text-[rgb(var(--text-secondary))]">
              {col.title} ({col.items.length})
            </h3>
          </div>
          <div className="p-2 space-y-2 min-h-[100px]">
            {col.items.map((item) => (
              <div
                key={item.id}
                draggable
                onDragStart={() => handleDragStart(item, col.id)}
                className="p-3 rounded-lg border cursor-grab active:cursor-grabbing transition-all bg-[rgb(var(--surface))] border-[rgb(var(--border))] hover:border-[rgb(var(--accent))]"
              >
                {renderItem(item)}
              </div>
            ))}
          </div>
        </div>
      ))}
    </div>
  );
}

Accessibility

  • Provide keyboard alternatives for all drag operations (e.g., Move Up/Down buttons).
  • Use aria-grabbed to indicate drag state for screen readers.
  • Announce drops with aria-live regions when order changes.
  • Ensure drop targets are focusable and indicate focus state.
  • Support Enter/Space to pick up and drop items via keyboard.
  • Provide visual focus indicators for keyboard navigation.