Back to Blog

Drag & Drop Kanban Board in Compose Desktop | Kotlin Desktop #10

Sandy LaneSandy Lane

Video: Drag & Drop Kanban Board in Compose Desktop | Kotlin Desktop #10 by Taught by Celeste AI - AI Coding Coach

Take the quiz on the full lesson page
Test what you've read · interactive walkthrough

Drag and Drop Kanban Board in Compose Desktop

dragAndDropSource plus dragAndDropTarget plus a mutableStateMapOf<String, Task>. Three columns, real drag-and-drop, working in 200 lines.

Today's app is a Kanban board: To Do, In Progress, Done. Tasks are cards that you drag from one column to another. The new pieces are Compose's drag-and-drop APIs, which on desktop integrate with the OS clipboard so drops work both within the app and from external sources.

What we are building

A 900×600 window with three columns. Each column has a coloured marker, the column name, a task count, and a list of task cards. Drag a card from one column → drop it on another. The card moves. A "+" button opens a dialog to add new tasks.

Project layout

src/main/kotlin/
├── Main.kt              # window
├── KanbanBoard.kt       # board + column composables + add dialog
├── TaskCard.kt          # draggable card
└── Task.kt              # Task data class + Column enum + sample data

Task.kt

enum class Column(val label: String) {
  TODO("To Do"),
  IN_PROGRESS("In Progress"),
  DONE("Done"),
}

data class Task(val id: String, val title: String, val column: Column)

fun sampleTasks(): Map<String, Task> = mapOf(
  "1" to Task("1", "Design UI mockups", Column.TODO),
  "2" to Task("2", "Set up database", Column.TODO),
  "3" to Task("3", "Write unit tests", Column.TODO),
)

Column is an enum with a display label. Column.entries (Kotlin 1.9+) gives you the list of all variants for iteration.

Task is the data class. id is a string so we can use it as a stable key in LazyColumn and as a transferable payload across the drag-and-drop boundary.

State: mutableStateMapOf

val tasks = remember { mutableStateMapOf<String, Task>().apply { putAll(sampleTasks()) } }

A mutableStateMapOf is Compose's reactive map. Reads from it (tasks[id], tasks.values) cause recomposition when the map changes. Mutations (tasks[id] = task.copy(...), tasks.remove(id)) trigger them.

For ordered collections, mutableStateListOf is the equivalent. We use a map here because the lookup key (task ID) is what the drag-and-drop layer transfers.

TaskCard.kt: making a card draggable

@OptIn(ExperimentalFoundationApi::class, ExperimentalComposeUiApi::class)
@Composable
fun TaskCard(task: Task, onDelete: () -> Unit, onDragStarted: () -> Unit) {
  Card(
    modifier = Modifier
      .fillMaxWidth()
      .padding(vertical = 4.dp)
      .dragAndDropSource(
        drawDragDecoration = {
          drawRoundRect(
            color = Color(0xFF6650A4),
            cornerRadius = CornerRadius(12f, 12f),
            alpha = 0.6f,
          )
        },
      ) {
        detectTapGestures(onLongPress = {
          onDragStarted()
          startTransfer(DragAndDropTransferData(
            transferable = DragAndDropTransferable(StringSelection(task.id)),
            supportedActions = listOf(DragAndDropTransferAction.Move),
          ))
        })
      },
    // ...
  ) {
    // card content
  }
}

Modifier.dragAndDropSource(drawDragDecoration = ...) { detectTapGestures(...) } makes any widget a draggable source.

drawDragDecoration is the visual that follows the cursor during drag. We draw a 60%-opacity purple rounded rectangle.

The trailing lambda is a gesture detector. detectTapGestures(onLongPress = { ... }) triggers on long-press — typical for desktop drag-and-drop initiation.

Inside the long-press, two things happen:

  1. onDragStarted() reports the task ID upward (so the parent knows what's being dragged).
  2. startTransfer(DragAndDropTransferData(...)) initiates the OS-level drag.

The transferable is StringSelection(task.id) — a java.awt.datatransfer.StringSelection carrying the task ID. The drop site reads this string to know which task was dropped. Using a string-based transferable means the data could even cross to other apps (e.g., dragging a task title into a text editor would paste the ID).

KanbanColumn: making a column a drop target

@OptIn(ExperimentalFoundationApi::class, ExperimentalComposeUiApi::class)
@Composable
fun KanbanColumn(
  column: Column,
  tasks: List<Task>,
  draggedTaskId: String?,
  onDragStarted: (String) -> Unit,
  onTaskDropped: (String) -> Unit,
  onDeleteTask: (String) -> Unit,
  modifier: Modifier = Modifier,
) {
  var isHovered by remember { mutableStateOf(false) }

  Card(
    modifier = modifier.fillMaxHeight().dragAndDropTarget(
      shouldStartDragAndDrop = { true },
      target = remember {
        object : DragAndDropTarget {
          override fun onDrop(event: DragAndDropEvent): Boolean {
            draggedTaskId?.let { onTaskDropped(it) }
            isHovered = false
            return true
          }
          override fun onEntered(event: DragAndDropEvent) { isHovered = true }
          override fun onExited(event: DragAndDropEvent) { isHovered = false }
          override fun onEnded(event: DragAndDropEvent) { isHovered = false }
        }
      },
    ),
    // ...
  ) {
    // header + LazyColumn of TaskCards
  }
}

Modifier.dragAndDropTarget(shouldStartDragAndDrop = { true }, target = ...) registers the column as a drop target.

shouldStartDragAndDrop returns true to accept any drag (you'd return false to filter, e.g., only accept tasks from your own app).

The DragAndDropTarget interface has four callbacks:

  • onDrop(event) — called when the user releases over this target. We tell the parent which task was dropped, return true to indicate we accepted.
  • onEntered — cursor enters the target. We flip isHovered = true for visual feedback.
  • onExited — cursor leaves. Reset isHovered.
  • onEnded — drag finishes (anywhere). Reset isHovered.

The hover state drives the column's background tint:

colors = CardDefaults.cardColors(
  containerColor = if (isHovered) columnColor.copy(alpha = 0.15f) else MaterialTheme.colorScheme.surface,
),

Hovering shows the user "yes, you can drop here." Standard UX touch.

The drop handler

onTaskDropped = { taskId ->
  tasks[taskId]?.let { task ->
    tasks[taskId] = task.copy(column = column)
  }
  draggedTaskId = null
}

Look up the task by ID, build a copy with the new column, write it back to the map. The map's reactivity triggers recomposition of all three columns (since they all read tasks.values), and the card moves visually.

Wiring at the board level

Row(
  modifier = Modifier.fillMaxSize(),
  horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
  Column.entries.forEach { column ->
    KanbanColumn(
      column = column,
      tasks = tasks.values.filter { it.column == column },
      draggedTaskId = draggedTaskId,
      onDragStarted = { taskId -> draggedTaskId = taskId },
      onTaskDropped = { taskId ->
        tasks[taskId]?.let { task ->
          tasks[taskId] = task.copy(column = column)
        }
        draggedTaskId = null
      },
      onDeleteTask = { taskId -> tasks.remove(taskId) },
      modifier = Modifier.weight(1f),
    )
  }
}

The board is a Row with three columns. Column.entries.forEach iterates the enum — adding a fourth Kanban column would mean adding a fourth enum variant; the UI wouldn't need to change.

Modifier.weight(1f) on each column distributes width evenly.

Adding a new task

if (showAddDialog) {
  var title by remember { mutableStateOf("") }
  AlertDialog(
    onDismissRequest = { showAddDialog = false },
    title = { Text("New Task") },
    text = {
      OutlinedTextField(
        value = title,
        onValueChange = { title = it },
        label = { Text("Task title") },
        singleLine = true,
        modifier = Modifier.fillMaxWidth(),
      )
    },
    confirmButton = {
      TextButton(onClick = {
        if (title.isNotBlank()) {
          val id = nextId.toString()
          tasks[id] = Task(id, title.trim(), Column.TODO)
          nextId++
          showAddDialog = false
        }
      }) { Text("Save") }
    },
    dismissButton = {
      TextButton(onClick = { showAddDialog = false }) { Text("Cancel") }
    },
  )
}

Standard Material AlertDialog from Lesson 7. New tasks always go to TODO; the user drags them onward.

nextId is a mutableIntStateOf(4) — auto-incrementing IDs. For a real app, use UUID.randomUUID().toString() for collision-free IDs.

Visual hover feedback

The column tints itself when a drag enters. The dragged card draws a purple decoration that follows the cursor. Together they make the user confident the drag-and-drop is working — without those cues, drag is invisible until the drop happens.

A small but high-impact UX detail. For free, just by using the API.

Drag-and-drop with external apps

Because we use StringSelection, the dragged task ID is transferable — drop it on any text input on your OS and the ID pastes. This is rarely useful for a Kanban board, but the same API supports file drops, image drops, etc., and you can read those types in the drop target's onDrop to integrate with the OS.

For Kanban specifically, you might want to receive drops from the OS — e.g., dropping a .txt file becomes a new task with the file's name. That's a small extension to onDrop to inspect event.transferable.

Common mistakes

Forgetting @OptIn(ExperimentalFoundationApi::class, ExperimentalComposeUiApi::class). Drag-and-drop is still flagged experimental on Compose 1.7. Without the opt-in, the modifiers don't compile.

Mutating a Task instead of copy-ing. task.column = newColumn doesn't compile (data classes have val fields). Use task.copy(column = newColumn).

Storing the dragged item directly instead of an ID. When the drag crosses async boundaries, the task object can be stale by the time the drop fires. The ID is stable; look up the current state at drop time.

No isHovered feedback. Without visual hover, drag feels broken. Always flip a state on onEntered/onExited.

Returning false from onDrop. Tells the framework you didn't accept the drop. The cursor shows "no drop" and the source's drag-end fires with no effect. Return true if you handled it.

What's next

Lesson 11: Canvas, Path, and pointer input. Build a drawing app — freehand strokes, palette, stroke width, undo, clear. The Canvas API for direct rendering, plus pointer-input gestures for capturing the drag.

Recap

Modifier.dragAndDropSource(drawDragDecoration) { detectTapGestures(onLongPress = ...) } for a draggable widget. Modifier.dragAndDropTarget(shouldStartDragAndDrop = { true }, target = DragAndDropTarget object) for a drop site. Transfer data via StringSelection(id). Look up by ID at drop time, mutate via copy(). mutableStateMapOf for reactive lookup-by-key state. Column.entries.forEach for enum-driven UIs.

Next lesson: drawing app.

Ready? Take the quiz on the full lesson page →
Test what you've learned. Watch the lesson and try the interactive quiz on the same page.