A Kanban board that
acts like a native control.
Conform two of your own types, hand it a binding, and you have a drag-and-drop board with a full keyboard move-mode and VoiceOver parity — not just pointer drag with accessibility bolted on after.
Why
Every existing SwiftUI Kanban is pointer-only.
That's the gap this fills. Keyboard and VoiceOver users aren't an afterthought bolted onto a drag gesture — they call the exact same move-resolution function pointer drag does, so behavior can't quietly diverge between input methods.
KanbanCard / KanbanColumn. No board-model type to keep in sync with your real data — the board drives reordering directly on your Binding<[Column]>.@ViewBuilder, so it never fights a fixed schema.wipLimit per column, choose .warnOnly or .preventDrop, and it's enforced identically whether the move came from a mouse, a keyboard, or VoiceOver.Quickstart
Add a board in three steps.
This is the whole integration — no configuration file, no board-state class to instantiate first.
-
Conform your card type to
KanbanCardIt only needs
IdentifiableandEquatable— most model types already qualify.import AdvancedKanban struct Task: KanbanCard { let id: UUID var title: String }
-
Conform your column type to
KanbanColumncardsorder is the board's display order — no separate sort index.wipLimitis optional;isCollapsedis a plain stored property the board toggles on tap.struct Stage: KanbanColumn { let id: UUID var name: String var cards: [Task] = [] var wipLimit: Int? = nil var isCollapsed: Bool = false }
-
Call
KanbanBoardGive it a binding and two view builders. That's a complete, draggable, keyboard- and VoiceOver-accessible board.
import SwiftUI import AdvancedKanban struct ContentView: View { @State private var columns: [Stage] = [ Stage(id: UUID(), name: "To Do", cards: [Task(id: UUID(), title: "Write README")]), Stage(id: UUID(), name: "In Progress", wipLimit: 3), Stage(id: UUID(), name: "Done"), ] var body: some View { KanbanBoard( columns: $columns, wipLimitBehavior: .preventDrop, onMove: { move in // persist however you like — SwiftData, a network call, analytics }, columnTitle: { $0.name }, // real names in VoiceOver's "Move to
" cardContent: { task in Text(task.title).padding(8) }, columnHeader: { stage in Text(stage.name).font(.headline) } ) } }
onMove fires after the board has already mutated your columns binding — it's purely a persistence hook. You never diff arrays yourself; the board did the mutation, the closure just tells you what happened.
Reference
KanbanBoard initializer
Every parameter, in order.
| Parameter | Type | What it does |
|---|---|---|
| columns | Binding<[Column]> | Your data. The board mutates it directly on drop. |
| wipLimitBehavior | WIPLimitBehavior | .warnOnly (default) or .preventDrop. |
| onMove | ((KanbanMove) -> Void)? | Fires after mutation — your persistence hook. |
| columnTitle | ((Column) -> String)? | Optional. Gives VoiceOver a real column name instead of the raw id. |
| cardContent | @ViewBuilder (Card) -> some View | Required. What's inside a card. |
| columnHeader | @ViewBuilder (Column) -> some View | Required. What's inside a column's header. |
See Theming for KanbanTheme, Accessibility for the full keyboard and VoiceOver model, and Persistence for the optional SwiftData adapter.
Read the design spec.
Every architectural decision — why the drag engine isn't built on Transferable, how the keyboard/VoiceOver/pointer paths share one code path, the theming boundary — is written down.
More from NerdSnipe Inc
A board is one screen. What about the other forty?
AdvancedKanban is a single component. If you're assembling the rest of the app around it, this is what we build the rest of our own apps out of.
Browse the SwiftUI screen templates →
View the free SwiftUI theming library →