Kanban
A kanban board for organizing items across draggable columns. Items can be moved between columns and reordered within a column using drag and drop, or with the keyboard. The component is fully controlled — the parent is responsible for updating the data after a move event.
Keyboard support
Tab to an item, press Space or Enter to grab it, use the arrow keys to move, Enter/Space to drop and Escape to cancel.
Props
aria-label?: string
Accessible label for the board, announced by screen readers.
can-move?: (event: FluxKanbanMoveEvent) => boolean
Optional validator. Return false to reject a drop. Called for both pointer and keyboard moves.
disabled?: boolean
Disables drag-and-drop on the entire board.
Default: false
reorderable-columns?: boolean
Allows columns to be reordered by dragging their header.
Default: false
Emits
move: [FluxKanbanMoveEvent]
Triggered when an item is dragged to a new position or column.
move-column: [FluxKanbanMoveColumnEvent]
Triggered when a column is reordered. Only fires when reorderable-columns is enabled.
Slots
default
Place FluxKanbanColumn components here.
Move event
The move event contains everything needed to update the data:
Property | Type | Description |
|---|---|---|
itemId | string | number | The ID of the item that was moved. |
fromColumnId | string | number | The column the item originated from. |
toColumnId | string | number | The column the item was dropped into. |
beforeItemId | string | number | undefined | The item before which the moved item should be inserted. undefined means append at the end of the column. |
Examples
Basic
A task board with draggable items.
<template>
<FluxKanban @move="onMove">
<FluxKanbanColumn
v-for="column in columns"
:key="column.id"
:column-id="column.id"
:label="column.label">
<FluxKanbanItem
v-for="card in getCards(column.id)"
:key="card.id"
:item-id="card.id"
:column-id="column.id">
<div class="card">
{{ card.title }}
</div>
</FluxKanbanItem>
</FluxKanbanColumn>
</FluxKanban>
</template>
<script
lang="ts"
setup>
import { ref } from 'vue';
import { FluxKanban, FluxKanbanItem, FluxKanbanColumn } from '@flux-ui/components';
import type { FluxKanbanMoveEvent } from '@flux-ui/types';
const columns = [
{id: 'todo', label: 'To do'},
{id: 'in-progress', label: 'In progress'},
{id: 'done', label: 'Done'}
];
const cards = ref([
{id: 1, columnId: 'todo', title: 'Design system review'},
{id: 2, columnId: 'todo', title: 'Write unit tests'},
{id: 3, columnId: 'todo', title: 'Update documentation'},
{id: 4, columnId: 'in-progress', title: 'Implement kanban component'},
{id: 5, columnId: 'in-progress', title: 'Fix layout bug'},
{id: 6, columnId: 'done', title: 'Set up project'},
{id: 7, columnId: 'done', title: 'Create color palette'}
]);
function getCards(columnId: string) {
return cards.value.filter(card => card.columnId === columnId);
}
function onMove({itemId, toColumnId, beforeItemId}: FluxKanbanMoveEvent): void {
const movedCard = cards.value.find(card => card.id === itemId);
if (!movedCard) {
return;
}
const updated = cards.value.filter(card => card.id !== itemId);
movedCard.columnId = String(toColumnId);
if (beforeItemId === undefined) {
updated.push(movedCard);
} else {
const beforeIndex = updated.findIndex(card => card.id === beforeItemId);
updated.splice(beforeIndex === -1 ? updated.length : beforeIndex, 0, movedCard);
}
cards.value = updated;
}
</script>
<style scoped>
.card {
padding: 12px;
background: var(--gray-25);
border: 1px solid var(--gray-200);
border-radius: var(--radius);
transition: box-shadow 180ms var(--swift-out);
}
.card:hover {
box-shadow: 0 1px 4px rgb(0 0 0 / .08);
}
</style>Custom item
Using the default slot to render rich item content.
Clean up token handling and session storage.
Build drag-and-drop kanban for the design system.
Align tokens with the new brand guide.
<template>
<FluxKanban @move="onMove">
<FluxKanbanColumn
v-for="column in columns"
:key="column.id"
:column-id="column.id"
:label="column.label">
<FluxKanbanItem
v-for="card in getCards(column.id)"
:key="card.id"
:item-id="card.id"
:column-id="column.id">
<div class="card">
<div class="card-header">
<span class="card-title">{{ card.title }}</span>
<FluxBadge
:color="priorityColor(card.priority)"
:label="card.priority"
type="none"/>
</div>
<p
v-if="card.description"
class="card-description">
{{ card.description }}
</p>
<div
v-if="card.assignee"
class="card-footer">
<span class="card-assignee">{{ card.assignee }}</span>
</div>
</div>
</FluxKanbanItem>
</FluxKanbanColumn>
</FluxKanban>
</template>
<script
lang="ts"
setup>
import { ref } from 'vue';
import { FluxBadge, FluxKanban, FluxKanbanItem, FluxKanbanColumn } from '@flux-ui/components';
import type { FluxColor, FluxKanbanMoveEvent } from '@flux-ui/types';
const columns = [
{id: 'backlog', label: 'Backlog'},
{id: 'in-progress', label: 'In progress'},
{id: 'review', label: 'Review'}
];
const cards = ref([
{id: 1, columnId: 'backlog', title: 'Refactor auth module', description: 'Clean up token handling and session storage.', priority: 'high', assignee: 'Alice'},
{id: 2, columnId: 'backlog', title: 'Add dark mode', description: null, priority: 'low', assignee: null},
{id: 3, columnId: 'in-progress', title: 'Kanban component', description: 'Build drag-and-drop kanban for the design system.', priority: 'high', assignee: 'Bob'},
{id: 4, columnId: 'in-progress', title: 'Fix pagination bug', description: null, priority: 'medium', assignee: 'Alice'},
{id: 5, columnId: 'review', title: 'Update color tokens', description: 'Align tokens with the new brand guide.', priority: 'medium', assignee: 'Carol'}
]);
function getCards(columnId: string) {
return cards.value.filter(card => card.columnId === columnId);
}
function priorityColor(priority: string): FluxColor {
if (priority === 'high') return 'danger';
if (priority === 'medium') return 'warning';
return 'gray';
}
function onMove({itemId, toColumnId, beforeItemId}: FluxKanbanMoveEvent): void {
const movedCard = cards.value.find(card => card.id === itemId);
if (!movedCard) {
return;
}
const updated = cards.value.filter(card => card.id !== itemId);
movedCard.columnId = String(toColumnId);
if (beforeItemId === undefined) {
updated.push(movedCard);
} else {
const beforeIndex = updated.findIndex(card => card.id === beforeItemId);
updated.splice(beforeIndex === -1 ? updated.length : beforeIndex, 0, movedCard);
}
cards.value = updated;
}
</script>
<style scoped>
.card {
padding: 12px;
background: var(--gray-25);
border: 1px solid var(--gray-200);
border-radius: var(--radius);
transition: box-shadow 180ms var(--swift-out);
}
.card:hover {
box-shadow: 0 1px 4px rgb(0 0 0 / .08);
}
.card-header {
display: flex;
align-items: flex-start;
gap: 8px;
justify-content: space-between;
}
.card-title {
font-size: .875rem;
font-weight: 500;
color: var(--foreground);
line-height: 1.4;
}
.card-description {
margin: 6px 0 0;
font-size: .8125rem;
color: var(--gray-500);
line-height: 1.5;
}
.card-footer {
display: flex;
align-items: center;
margin-top: 10px;
padding-top: 10px;
border-top: 1px solid var(--gray-100);
}
.card-assignee {
font-size: .8125rem;
color: var(--gray-500);
}
</style>Disabled
A read-only board — drag-and-drop is disabled.
<template>
<FluxKanban disabled>
<FluxKanbanColumn
v-for="column in columns"
:key="column.id"
:column-id="column.id"
:label="column.label">
<FluxKanbanItem
v-for="card in getCards(column.id)"
:key="card.id"
:item-id="card.id"
:column-id="column.id">
<div class="card">
{{ card.title }}
</div>
</FluxKanbanItem>
</FluxKanbanColumn>
</FluxKanban>
</template>
<script
lang="ts"
setup>
import { FluxKanban, FluxKanbanItem, FluxKanbanColumn } from '@flux-ui/components';
const columns = [
{id: 'todo', label: 'To do'},
{id: 'in-progress', label: 'In progress'},
{id: 'done', label: 'Done'}
];
const cards = [
{id: 1, columnId: 'todo', title: 'Read-only board'},
{id: 2, columnId: 'in-progress', title: 'Cards cannot be picked up'},
{id: 3, columnId: 'done', title: 'Useful for archived projects'}
];
function getCards(columnId: string) {
return cards.filter(card => card.columnId === columnId);
}
</script>
<style scoped>
.card {
padding: 12px;
background: var(--gray-25);
border: 1px solid var(--gray-200);
border-radius: var(--radius);
}
</style>Validation
Use `can-move` to reject specific drops.
<template>
<FluxKanban
:can-move="canMove"
@move="onMove">
<FluxKanbanColumn
v-for="column in columns"
:key="column.id"
:column-id="column.id"
:label="column.label">
<FluxKanbanItem
v-for="card in getCards(column.id)"
:key="card.id"
:item-id="card.id"
:column-id="column.id">
<div class="card">
{{ card.title }}
</div>
</FluxKanbanItem>
</FluxKanbanColumn>
</FluxKanban>
</template>
<script
lang="ts"
setup>
import { ref } from 'vue';
import { FluxKanban, FluxKanbanItem, FluxKanbanColumn } from '@flux-ui/components';
import type { FluxKanbanMoveEvent } from '@flux-ui/types';
const columns = [
{id: 'todo', label: 'To do'},
{id: 'in-progress', label: 'In progress'},
{id: 'done', label: 'Done'}
];
const cards = ref([
{id: 1, columnId: 'todo', title: 'Plan sprint'},
{id: 2, columnId: 'todo', title: 'Refine backlog'},
{id: 3, columnId: 'in-progress', title: 'Build kanban'},
{id: 4, columnId: 'done', title: 'Setup project'}
]);
function getCards(columnId: string) {
return cards.value.filter(card => card.columnId === columnId);
}
// Disallow moving cards directly from "todo" to "done" — they have to pass through "in-progress" first.
function canMove({fromColumnId, toColumnId}: FluxKanbanMoveEvent): boolean {
return !(fromColumnId === 'todo' && toColumnId === 'done');
}
function onMove({itemId, toColumnId, beforeItemId}: FluxKanbanMoveEvent): void {
const movedCard = cards.value.find(card => card.id === itemId);
if (!movedCard) {
return;
}
const updated = cards.value.filter(card => card.id !== itemId);
movedCard.columnId = String(toColumnId);
if (beforeItemId === undefined) {
updated.push(movedCard);
} else {
const beforeIndex = updated.findIndex(card => card.id === beforeItemId);
updated.splice(beforeIndex === -1 ? updated.length : beforeIndex, 0, movedCard);
}
cards.value = updated;
}
</script>
<style scoped>
.card {
padding: 12px;
background: var(--gray-25);
border: 1px solid var(--gray-200);
border-radius: var(--radius);
transition: box-shadow 180ms var(--swift-out);
}
.card:hover {
box-shadow: 0 1px 4px rgb(0 0 0 / .08);
}
</style>Reorderable columns
Drag the column header to change column order.
<template>
<FluxKanban
reorderable-columns
@move="onMove"
@move-column="onMoveColumn">
<FluxKanbanColumn
v-for="column in columns"
:key="column.id"
:column-id="column.id"
:label="column.label">
<FluxKanbanItem
v-for="card in getCards(column.id)"
:key="card.id"
:item-id="card.id"
:column-id="column.id">
<div class="card">
{{ card.title }}
</div>
</FluxKanbanItem>
</FluxKanbanColumn>
</FluxKanban>
</template>
<script
lang="ts"
setup>
import { ref } from 'vue';
import { FluxKanban, FluxKanbanItem, FluxKanbanColumn } from '@flux-ui/components';
import type { FluxKanbanMoveColumnEvent, FluxKanbanMoveEvent } from '@flux-ui/types';
const columns = ref([
{id: 'todo', label: 'To do'},
{id: 'in-progress', label: 'In progress'},
{id: 'review', label: 'Review'},
{id: 'done', label: 'Done'}
]);
const cards = ref([
{id: 1, columnId: 'todo', title: 'Define column order'},
{id: 2, columnId: 'in-progress', title: 'Drag a column header to reorder'},
{id: 3, columnId: 'done', title: 'Cards still drag normally'}
]);
function getCards(columnId: string) {
return cards.value.filter(card => card.columnId === columnId);
}
function onMove({itemId, toColumnId, beforeItemId}: FluxKanbanMoveEvent): void {
const movedCard = cards.value.find(card => card.id === itemId);
if (!movedCard) {
return;
}
const updated = cards.value.filter(card => card.id !== itemId);
movedCard.columnId = String(toColumnId);
if (beforeItemId === undefined) {
updated.push(movedCard);
} else {
const beforeIndex = updated.findIndex(card => card.id === beforeItemId);
updated.splice(beforeIndex === -1 ? updated.length : beforeIndex, 0, movedCard);
}
cards.value = updated;
}
function onMoveColumn({columnId, beforeColumnId}: FluxKanbanMoveColumnEvent): void {
const moved = columns.value.find(column => column.id === columnId);
if (!moved) {
return;
}
const remaining = columns.value.filter(column => column.id !== columnId);
if (beforeColumnId === undefined) {
remaining.push(moved);
} else {
const beforeIndex = remaining.findIndex(column => column.id === beforeColumnId);
remaining.splice(beforeIndex === -1 ? remaining.length : beforeIndex, 0, moved);
}
columns.value = remaining;
}
</script>
<style scoped>
.card {
padding: 12px;
background: var(--gray-25);
border: 1px solid var(--gray-200);
border-radius: var(--radius);
transition: box-shadow 180ms var(--swift-out);
}
.card:hover {
box-shadow: 0 1px 4px rgb(0 0 0 / .08);
}
</style>