Kanban
A board-and-column layout for task management, pipelines, and workflow visualization. Supports horizontal scrolling, WIP limits, intent-based column accents, and drag-and-drop integration.
Basic Usage
On paper, where nothing scrolls sideways, the columns of a horizontal board flow onto the next line, so every column prints. To give a row of your own the same, see data-wk-print-wrap.
Intent-Based Column Accents
Color the top border to visually distinguish column stages:
WIP Limits
Set a limit per column. The count badge turns danger when the column exceeds capacity:
Column Footer
Add actions (like "Add item") below the cards:
Custom Column Header
Replace the default header with your own content:
Vertical Orientation
Stack columns vertically instead of horizontally:
Reordering
Add sortable to the board and to each column you want to reorder. Cards can then be moved by dragging, by keyboard, or with two clicks once the column's Reorder button is pressed.
Give each card a data-sortable-id so the new order means something to your application. Without one, a card falls back to its position — which only works for a list your server addresses positionally.
What you receive
When a move finishes, the column dispatches one bubbling wirekit:sortable:reordered event:
{{-- 1. The event bubbles, so listen anywhere above the board. --}}
<div wire:wirekit:sortable:reordered="reorder($event.detail.order)">
<x-wirekit::kanban sortable>
{{-- 2. Each card names itself; the event carries these ids. --}}
<x-wirekit::kanban-column label="Backlog" sortable>
@foreach($tasks as $task)
<x-wirekit::card :data-sortable-id="$task->id">
<x-wirekit::card.body>{{ $task->title }}</x-wirekit::card.body>
</x-wirekit::card>
@endforeach
</x-wirekit::kanban-column>
</x-wirekit::kanban>
</div>
// 3. The whole sequence arrives at once, so persist it as one statement.
public function reorder(array $order): void
{
foreach ($order as $position => $id) {
Task::whereKey($id)->update(['position' => $position]);
}
}
The payload is { order, id, from, to }: the complete new sequence, plus which card moved and where from and to.
One event, at the end — not a swap per crossing. A stream of swaps makes the final state depend on the order the responses arrive in, which is network timing: wrong whenever two are in flight, and impossible to test. A drag cannot promise the order of its own intermediate steps, so it does not report them.
Moving cards between columns
Add cross-column to a sortable board and give each column a column-id. A card can then leave its column: dragged onto another one, carried there with the arrow keys while it is lifted, or placed there with a click in reorder mode.
With the keyboard, ← and → carry a lifted card into the previous or next column, and ↑ ↓ keep moving it within one. Escape returns it to the column it started in, not only to a position in the one it happens to be in.
A move between columns is reported once, from the column the card lands in, as a bubbling wirekit:sortable:moved event:
{{-- 1. The event bubbles, so listen anywhere above the board. --}}
<div wire:wirekit:sortable:moved="moveCard($event.detail)">
<x-wirekit::kanban sortable cross-column>
@foreach($columns as $column)
{{-- 2. The column-id comes back as from.column and to.column. --}}
<x-wirekit::kanban-column :label="$column->name" :column-id="$column->key" sortable>
@foreach($column->tasks as $task)
<x-wirekit::card :data-sortable-id="$task->id">
<x-wirekit::card.body>{{ $task->title }}</x-wirekit::card.body>
</x-wirekit::card>
@endforeach
</x-wirekit::kanban-column>
@endforeach
</x-wirekit::kanban>
</div>
// 3. Both columns' new order arrives together, so one transaction covers the whole move.
public function moveCard(array $move): void
{
DB::transaction(function () use ($move) {
Task::whereKey($move['id'])->update(['status' => $move['to']['column']]);
foreach (['from', 'to'] as $side) {
foreach ($move[$side]['order'] as $position => $id) {
Task::whereKey($id)->update(['position' => $position]);
}
}
});
}
The payload is { id, from: { column, index, order }, to: { column, index, order } }. A move inside one column arrives as wirekit:sortable:reordered, as it does without cross-column, so a board that turns this on keeps its handler for that case.
Without cross-column nothing changes: the arrow keys keep moving a lifted card within its own column, and a card dragged over another column is refused. It is off by default because it changes what ← and → do.
Kanban Board Props
| Prop | Type | Default | Description |
|---|---|---|---|
orientation |
string |
'horizontal' |
Layout direction: horizontal or vertical |
sortable |
bool |
false |
Marks the board as sortable. The cards themselves are reordered by sortable on each column, and crossColumn builds on this one |
crossColumn |
bool |
false |
Lets a card move between sortable columns, by drag, by ← → while lifted, and by a click in reorder mode — see Moving cards between columns. Needs sortable |
scope |
string|null |
null |
Scoped personalization name |
Kanban Column Props
| Prop | Type | Default | Description |
|---|---|---|---|
label |
string|null |
null |
Column heading text. It also names the column's scrollable body and makes it a screen-reader landmark — see Accessibility |
count |
int |
null |
Number of items (shown as badge) |
intent |
string |
'neutral' |
Top border accent: neutral, primary, success, warning, danger, info |
limit |
int |
null |
WIP limit (badge turns danger when exceeded) |
sortable |
bool |
false |
Makes the cards in this column reorderable by drag, by keyboard, and by clicks in reorder mode through the Reorder button in its header |
columnId |
string|null |
null |
What your application calls this column. It comes back as from.column and to.column when a card moves between columns; without one the column is named by its position |
scope |
string|null | null |
Scoped personalization name |
Column Slots
| Slot | Description |
|---|---|
| default | Card items inside the column |
header |
Replace the default column header |
footer |
Action area below the cards |
Accessibility
- Board:
role="list"for the column collection - Column:
<section role="listitem">, named from itslabel. With the default header the name is anaria-labelledbyreference to the header text; when you replace the header through theheaderslot there is no label element of ours left to point at, so the samelabelbecomes anaria-labelinstead. A column with nolabelis an unnamed list item — pass one. - Sortable: reordering is operable by keyboard, by dragging, and with single clicks in reorder mode for a pointer that cannot drag (WCAG 2.2 SC 2.5.7) — see below
- Each card is focusable and carries
aria-roledescription="Sortable item"while a column is sortable - On a
cross-columnboard, a card carried into another column is announced with that column'slabelbefore its new position, and focus stays on the card - Column body:
tabindex="0"and a focus ring in every configuration, so the scrolling card list is keyboard-reachable even when the cards inside hold no other focusable element (WCAG 2.1.1) - The column body becomes a screen-reader landmark only when the column has a
label. It is then arole="region"under that same name. Without one it stays a plain focusable scroller —role="region"plus a name is a landmark, and a six-column board falling back to one built-in name would be six landmarks announcing the same thing, which axe reports aslandmark-unique. Every column on this page is labeled, which is the shape to copy.
Keyboard Interaction
| Key | Action |
|---|---|
Tab |
Move focus through cards |
Space / Enter |
Lift the focused card, or drop the one you lifted |
↓ |
Move the lifted card one place later |
↑ |
Move the lifted card one place earlier |
→ |
Move the lifted card one place later — on a cross-column board, into the next column |
← |
Move the lifted card one place earlier — on a cross-column board, into the previous column |
Escape |
Abandon the move and put the card back where it started, in its own column |
Reordering by keyboard is part of the feature rather than an addition to it: a list that can only be reordered by dragging cannot be reordered at all by someone not using a pointer.
Reordering with clicks
A sortable column shows a Reorder button in its header. Pressed, it puts the column in reorder mode, and on a cross-column board every column at once. Its accessible name carries the column's label, "Reorder To do", so a board of separately sortable columns does not offer several buttons with one name. On a cross-column board every one of them does the same thing, and each is named Reorder. In that mode:
- A click on a card picks it up. It takes the focus and is drawn as lifted.
- A click on another card puts the picked card in that card's place. On a
cross-columnboard the other card can be in another column. - A click on a column's empty space puts the card last in that column.
- A click on the picked card,
SpaceorEnterputs it down where it is.Escapeputs it down where it was. - Pressing Reorder again ends the mode, and a card still picked up is put down where it was.
This is how a card moves with a pointer that clicks but cannot drag, such as a head pointer, a mouth stick or eye tracking. While the mode is on, a click belongs to the reorder: links and handlers inside the cards do not run. A move reports once, through the same wirekit:sortable:reordered or wirekit:sortable:moved event as a drag.
Styling a card while it moves
A card carries a state attribute for as long as it is in motion. Both are removed when the move ends or is abandoned:
| Attribute | While |
|---|---|
data-sortable-dragging |
The card is being dragged with a pointer |
data-sortable-lifted |
The card has been lifted with the keyboard, or picked up with a click in reorder mode, and is being moved |
A focused card wears the focus ring, and a lifted card is drawn apart from it: a dashed outline in the accent and a raised shadow. To give the lifted state a treatment of your own, name the focused state as well, because a lifted card always has the focus:
/* 1. A dragged card is under the pointer and already reads as "held". */
[data-sortable-dragging] { opacity: 0.5; }
/* 2. Your own lifted state, focused or not. */
[data-sortable-lifted],
[data-sortable-lifted]:focus-visible { outline: 2px dashed var(--color-wk-accent); outline-offset: 4px; }
Pitfalls
- Give every card a
data-sortable-id. Positional fallbacks are silent and correct until the list is filtered or paginated. - Persist the sequence you are handed, not a swap. The event gives you the whole order for exactly this reason.
- Give every column a
column-idon across-columnboard. Without one a column is named by its position, which stays correct only until a column is added, hidden or reordered. - The count badge and the WIP limit are your server's numbers. They change when your component re-renders after saving the move, not at the moment of the drop.
- Refuse a move by rendering the order you keep. A Livewire method that does not save the move, a full column for example, re-renders the board, and the card goes back to the place your server keeps. The column then says where the card is, "Could not save. Back at position 1 of 3.", since it had announced the drop. A request that fails without a render leaves the card where it was dropped until the next one.
- Listen for both events on a
cross-columnboard. A move inside a column arrives aswirekit:sortable:reordered, a move between columns aswirekit:sortable:moved, and a board that handles only one of them silently drops the other kind of move.
Design Tokens
| Token | Used for |
|---|---|
--font-wk-sans |
Kanban font family |
--font-wk-heading-weight |
Column title weight |
--text-wk-sm |
Column title + count badge font size |
--color-wk-text |
Column title |
--color-wk-bg-muted |
Column body background |
--color-wk-border / --color-wk-border-subtle |
Column border + separator |
--color-wk-accent / --color-wk-success / --color-wk-warning / --color-wk-danger |
Top-border accent per intent |
--color-wk-ring / --color-wk-ring-offset |
Focus ring |
--ring-wk-width / --ring-wk-offset |
Focus ring geometry |
--border-wk-width |
Border width |
--radius-wk-lg |
Column border radius |