Skip to content

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.

To do
Design system review
high
Write unit tests
medium
Update documentation
In progress
Implement kanban component
high
Fix layout bug
low
Done
Set up project
Create color palette

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.

To do
Design system review
Write unit tests
Update documentation
In progress
Implement kanban component
Fix layout bug
Done
Set up project
Create color palette

<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.

Backlog
Refactor auth module
high

Clean up token handling and session storage.

Add dark mode
low
In progress
Kanban component
high

Build drag-and-drop kanban for the design system.

Fix pagination bug
medium
Review
Update color tokens
medium

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.

To do
Read-only board
In progress
Cards cannot be picked up
Done
Useful for archived projects

<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.

To do
Plan sprint
Refine backlog
In progress
Build kanban
Done
Setup project

<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.

To do
Define column order
In progress
Drag a column header to reorder
Review
Done
Cards still drag normally

<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>

Used components