Skip to content

Kanban Board

Columns of cards that the user drags from one column to another.

Kanban Board, playing
Download .swift
Origin
Original work
Found by
kanban · board · drag and drop · columns · cards · tasks · trello · kanban · tablero · arrastrar y soltar · columnas · tarjetas · tareas

Columns of cards that move from one column to another by dragging, with a context menu and VoiceOver actions for anyone who cannot drag.

The drag payload is the card's id as plain text, so a copied file does not need a custom type in Info.plist. Text that matches no card is ignored.

struct KanbanBoardDemo: View {
@State private var columns = [
KanbanBoardColumn(id: "todo", title: "To do", cards: [
KanbanBoardCard(id: "1", title: "Sketch the onboarding"),
KanbanBoardCard(id: "2", title: "Write release notes"),
KanbanBoardCard(id: "3", title: "Fix the login crash"),
]),
KanbanBoardColumn(id: "doing", title: "Doing", cards: [
KanbanBoardCard(id: "4", title: "Record the previews"),
]),
KanbanBoardColumn(id: "done", title: "Done", cards: []),
]
var body: some View {
// Narrow enough to show part of the third column, which says the board scrolls.
KanbanBoard(columns: $columns, columnWidth: 150)
.tint(.indigo)
.background(LinearGradient(colors: [.indigo.opacity(0.14), .teal.opacity(0.1)], startPoint: .top, endPoint: .bottom))
}
}

init(columns:columnWidth:)

ParameterTypeDefault
columnsBinding<[KanbanBoardColumn]>Required
columnWidthCGFloat220

Copy this code into your project, or download the file.

KanbanBoard.swift
// SPDX-License-Identifier: MIT
// Copyright (c) 2026 Oscar Canton and SwiftUI Blocks contributors
import SwiftUI
struct KanbanBoardCard: Identifiable, Hashable, Sendable {
let id: String
var title: String
}
struct KanbanBoardColumn: Identifiable, Hashable, Sendable {
let id: String
var title: String
var cards: [KanbanBoardCard]
}
enum KanbanBoardMove {
/// Moves a card to the end of another column. Returns `false`, changing nothing,
/// when the card or the column does not exist or the card is already there.
static func apply(cardID: String, toColumn columnID: String, in columns: inout [KanbanBoardColumn]) -> Bool {
guard let target = columns.firstIndex(where: { $0.id == columnID }),
let source = columns.firstIndex(where: { $0.cards.contains { $0.id == cardID } }),
source != target,
let position = columns[source].cards.firstIndex(where: { $0.id == cardID })
else { return false }
let card = columns[source].cards.remove(at: position)
columns[target].cards.append(card)
return true
}
}
/// Columns of cards that move from one column to another by dragging, with a
/// context menu and VoiceOver actions for anyone who cannot drag.
///
/// The drag payload is the card's `id` as plain text, so a copied file does not
/// need a custom type in Info.plist. Text that matches no card is ignored.
struct KanbanBoard: View {
@Binding var columns: [KanbanBoardColumn]
@ScaledMetric private var columnWidth: CGFloat
@State private var targetedColumnID: String?
@Environment(\.accessibilityReduceMotion) private var reduceMotion
private let cardCornerRadius: CGFloat = 12
init(columns: Binding<[KanbanBoardColumn]>, columnWidth: CGFloat = 220) {
_columns = columns
_columnWidth = ScaledMetric(wrappedValue: columnWidth, relativeTo: .body)
}
var body: some View {
ScrollView([.horizontal, .vertical]) {
HStack(alignment: .top, spacing: 12) {
ForEach(columns) { column in
columnView(column)
}
}
.padding()
}
}
private func columnView(_ column: KanbanBoardColumn) -> some View {
VStack(alignment: .leading, spacing: 10) {
HStack {
Text(column.title).font(.headline)
Spacer()
Text("\(column.cards.count)")
.font(.subheadline)
.foregroundStyle(.secondary)
.accessibilityLabel("^[\(column.cards.count) card](inflect: true)")
}
.accessibilityElement(children: .combine)
ForEach(column.cards) { card in
cardView(card, in: column)
}
if column.cards.isEmpty {
Text("Drop cards here")
.font(.footnote)
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, minHeight: 60)
}
}
.padding(12)
.frame(width: max(120, columnWidth), alignment: .topLeading)
.background(
targetedColumnID == column.id ? AnyShapeStyle(.tint.opacity(0.22)) : AnyShapeStyle(.quaternary.opacity(0.5)),
in: .rect(cornerRadius: 18, style: .continuous)
)
.dropDestination(for: String.self) { cardIDs, _ in
var didMove = false
withAnimation(moveAnimation) {
for cardID in cardIDs {
if KanbanBoardMove.apply(cardID: cardID, toColumn: column.id, in: &columns) { didMove = true }
}
}
return didMove
} isTargeted: { isTargeted in
if isTargeted {
targetedColumnID = column.id
} else if targetedColumnID == column.id {
targetedColumnID = nil
}
}
}
private func cardView(_ card: KanbanBoardCard, in column: KanbanBoardColumn) -> some View {
cardLabel(card)
.draggable(card.id) { cardLabel(card).frame(width: max(120, columnWidth) - 24) }
.contextMenu { moveButtons(card, from: column) }
.accessibilityActions { moveButtons(card, from: column) }
}
private func cardLabel(_ card: KanbanBoardCard) -> some View {
Text(card.title)
.font(.subheadline)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(12)
.background(.background, in: .rect(cornerRadius: cardCornerRadius, style: .continuous))
.contentShape(.dragPreview, .rect(cornerRadius: cardCornerRadius, style: .continuous))
}
@ViewBuilder
private func moveButtons(_ card: KanbanBoardCard, from source: KanbanBoardColumn) -> some View {
ForEach(columns.filter { $0.id != source.id }) { destination in
Button("Move to \(destination.title)") {
withAnimation(moveAnimation) {
_ = KanbanBoardMove.apply(cardID: card.id, toColumn: destination.id, in: &columns)
}
}
}
}
private var moveAnimation: Animation? { reduceMotion ? nil : .snappy(duration: 0.3) }
}
// MARK: - Demo
struct KanbanBoardDemo: View {
@State private var columns = [
KanbanBoardColumn(id: "todo", title: "To do", cards: [
KanbanBoardCard(id: "1", title: "Sketch the onboarding"),
KanbanBoardCard(id: "2", title: "Write release notes"),
KanbanBoardCard(id: "3", title: "Fix the login crash"),
]),
KanbanBoardColumn(id: "doing", title: "Doing", cards: [
KanbanBoardCard(id: "4", title: "Record the previews"),
]),
KanbanBoardColumn(id: "done", title: "Done", cards: []),
]
var body: some View {
// Narrow enough to show part of the third column, which says the board scrolls.
KanbanBoard(columns: $columns, columnWidth: 150)
.tint(.indigo)
.background(LinearGradient(colors: [.indigo.opacity(0.14), .teal.opacity(0.1)], startPoint: .top, endPoint: .bottom))
}
}
#Preview {
KanbanBoardDemo()
}