Kanban Board

Build an advanced Vue.js Kanban board with drag-and-drop task movement, column-based layouts, and structured state management

Time to implement the project: ~ 45-65 hours

  • Vue.js
  • Composition API
  • Drag and Drop
  • Complex State Management
  • Column-Based Layouts
  • Reactive Data Flow
  • Task Interactions
  • UI Architecture

In this advanced Vue.js project, you will build a Kanban board that allows users to organize tasks across multiple workflow columns such as Backlog, To Do, In Progress, Review, and Done. The board should support drag-and-drop movement between columns, task ordering inside each column, task creation, editing, deletion, and a responsive layout that remains usable even when the board contains many cards.

This project is advanced because it combines several real-world frontend challenges in one interface. You are not only rendering cards on a page; you are managing nested state, updating task positions, reacting to drag events, preserving column structure, handling user actions, and keeping the UI predictable after every move. A well-built Kanban board teaches the kind of architecture used in project management tools, issue trackers, sprint boards, CRM pipelines, hiring pipelines, and internal workflow systems.

Project Goal and Advanced Learning Value

The main goal of this project is to help you understand how Vue.js can manage complex interactive interfaces with structured reactive state. A Kanban board looks simple from the outside, but internally it requires careful organization of columns, tasks, indexes, drag metadata, active states, and update rules. Every drag action changes the relationship between data and UI, so the project forces you to think beyond basic components.

You will learn how to design state that is easy to update and difficult to break. For example, each column should have a stable identifier, each task should have its own id, and drag actions should update the correct source and target locations. This prevents common bugs such as duplicated tasks, disappearing cards, incorrect ordering, or columns losing their original structure after repeated interactions.

The project also gives you practical experience with advanced component boundaries. A strong implementation may include BoardView, KanbanColumn, TaskCard, TaskModal, DragOverlay, ColumnHeader, and TaskForm components. Each component should have a clear responsibility, while shared board state remains centralized enough to keep updates predictable. This is the type of architecture expected in larger Vue.js applications.

Recommended Knowledge Before You Start

This is an advanced-level Vue.js project, so you should already be confident with components, props, emits, reactive state, computed values, watchers, event handling, and conditional rendering. You should also understand how Vue updates the DOM based on reactive data, because drag-and-drop interfaces can become unstable if the data structure and rendered output are not synchronized correctly.

You do not need a backend for the first version, but the board should still be designed as if it could later connect to an API. That means using clear ids, predictable data shapes, and update functions that could eventually be replaced with API calls. You may store the board in local state, Pinia, or localStorage, depending on how realistic you want the implementation to be.

  • Strong understanding of Vue.js components, props, emits, slots, and reactive data flow
  • Experience with the Composition API, ref, reactive, computed, watch, and composable functions
  • Ability to manage nested arrays and objects without causing accidental mutation problems
  • Understanding of drag events, pointer behavior, drop zones, hover states, and visual feedback
  • Comfort with responsive layouts, horizontal scrolling boards, sticky headers, and column sizing
  • Basic knowledge of localStorage, Pinia, or another strategy for persisting board state

Core Features of the Kanban Board

The Kanban board should behave like a simplified but realistic workflow management tool. Users should be able to create tasks, organize them by workflow stage, move cards between columns, reorder tasks, and clearly understand where each task belongs. The challenge is to make every interaction feel natural while keeping the underlying state clean and reliable.

Feature Implementation Focus
Column-based board layout Create multiple workflow columns with stable ids, titles, task counts, and independent task lists. The layout should support horizontal scrolling on smaller screens.
Task card rendering Display task cards with title, description preview, priority, status, assignee, tags, due date, or any other metadata that makes the board feel realistic.
Drag-and-drop movement Allow users to drag cards between columns and update the task’s column relationship correctly. The source column and target column should both update without duplication.
Task reordering inside columns Support changing the order of cards within the same column. This requires tracking source index, target index, and updating arrays in a predictable way.
Task creation and editing Add a form or modal that allows users to create new tasks and edit existing task details. Form state should be validated before updating the board.
Centralized board state Keep columns and tasks organized in a clear data structure. The project should avoid scattered state updates that make drag behavior difficult to debug.
Visual drag feedback Show active card styles, drop target highlights, placeholder spacing, or subtle transitions so users understand what will happen when they release the card.
Persistence layer Save board changes to localStorage or a store so the board does not reset after refresh. This makes the project feel more like a real productivity tool.
Responsive board behavior Make the board usable on desktop, tablet, and smaller screens. Advanced boards often need horizontal scrolling, compact cards, and touch-friendly drag areas.

Implementation Guidance for Advanced Vue Developers

Start by designing the data model before building drag interactions. A reliable structure might store columns as an ordered array and tasks either inside each column or in a normalized task map with column task ids. For smaller projects, nested tasks inside columns are easier to understand. For more scalable architecture, normalized data can make updates, lookups, and future API integration cleaner.

Build the board in stages. First render static columns and cards. Then add task creation and editing. After that, implement drag-and-drop movement between columns. Only when that works reliably should you add reordering inside the same column, persistence, animations, and advanced visual feedback. This order helps prevent the most common mistake: adding complex drag behavior before the data model is stable.

Be careful with drag events and reactive updates. Drag-and-drop can trigger many events quickly, and careless state changes can cause flickering, incorrect indexes, or dropped cards appearing in the wrong place. Keep drag metadata explicit: active task id, source column id, source index, target column id, and target index. Clear this metadata after each drop or cancel action so the next drag starts from a clean state.

  • Give every column and task a stable unique id instead of relying only on array indexes
  • Separate visual drag state from permanent board data to avoid accidental updates too early
  • Update source and target columns in one controlled function after a successful drop
  • Use computed values for task counts, filtered views, or derived board summaries
  • Keep form state isolated from saved task data until the user confirms changes
  • Persist board data after meaningful changes, not on every minor hover event
  • Test moving cards within the same column, between columns, to empty columns, and back again
  • Design the layout for wide content because real Kanban boards often exceed the viewport width

Common Mistakes When Building a Kanban Board

1. Using array indexes as task IDs and Vue keys

A Kanban board depends on stable identity. Every task can move between columns, change position inside the same column, be edited, deleted, filtered, persisted, and restored after reload. A common mistake is using array indexes as task IDs or Vue :key values. This works only while the list never changes. As soon as a user drags a card, removes a task, or filters the board, indexes shift and Vue may reuse the wrong DOM node.

This bug is especially frustrating in drag-and-drop interfaces because the UI can look correct for a moment and then display duplicated cards, stale card content, or wrong edit modal data. The problem is not Vue itself; the problem is that the data model does not give Vue a reliable way to identify each task.

A real Kanban board should use stable IDs for both columns and tasks. The column ID should not depend on the column title, because users may rename columns later. The task ID should not depend on the task position, because task position changes constantly. Generate IDs when the item is created and keep those IDs unchanged for the lifetime of the item.

Problematic approach:


          <template>
            <div class="kanban-column">
              <TaskCard
                v-for="(task, index) in column.tasks"
                :key="index"
                :task="task"
                @edit="openTaskEditor(index)"
              />
            </div>
          </template>

This makes the task identity depend on its current position. If the task moves from index 0 to index 3, Vue sees a different key and the edit logic may point to the wrong task.

Better data model:


          type TaskPriority = "low" | "medium" | "high";

          type KanbanTask = {
            id: string;
            columnId: string;
            title: string;
            description: string;
            priority: TaskPriority;
            createdAt: string;
            dueDate?: string;
          };

          type KanbanColumn = {
            id: string;
            title: string;
            taskIds: string[];
          };

          type KanbanBoard = {
            columns: KanbanColumn[];
            tasks: Record<string, KanbanTask>;
          };

Stable Vue rendering:


          <template>
            <section class="kanban-column">
              <TaskCard
                v-for="taskId in column.taskIds"
                :key="taskId"
                :task="tasks[taskId]"
                @edit="openTaskEditor(taskId)"
              />
            </section>
          </template>

Creating a task with a stable ID:


          function createTask(input: {
            columnId: string;
            title: string;
            description: string;
            priority: TaskPriority;
            dueDate?: string;
          }): KanbanTask {
            return {
              id: crypto.randomUUID(),
              columnId: input.columnId,
              title: input.title,
              description: input.description,
              priority: input.priority,
              dueDate: input.dueDate,
              createdAt: new Date().toISOString()
            };
          }

Pay attention to: Never use array indexes as task identity in a Kanban board. Stable IDs make drag-and-drop, editing, persistence, filtering, and Vue rendering much safer.

2. Mutating board data too early during drag hover

Drag-and-drop creates many intermediate events. The user starts dragging, enters a column, hovers over another card, moves away, comes back, and finally drops the task. A common mistake is updating the permanent board state during every hover event. This can create flickering, duplicated tasks, incorrect ordering, and very noisy localStorage writes.

A better design separates temporary drag state from permanent board state. Temporary drag state describes what the user is currently doing: active task ID, source column, source index, target column, and target index. The board data should be updated only when the drop is confirmed. This makes the drag behavior easier to debug because one controlled function is responsible for the final move.

This separation also improves visual feedback. You can highlight a target column, show a placeholder, or change the opacity of the active card without changing the saved board. If the user cancels the drag or drops outside a valid area, the board stays unchanged.

Problematic approach:


          function onDragEnter(targetColumnId: string, taskId: string) {
            const sourceColumn = board.columns.find((column) => {
              return column.taskIds.includes(taskId);
            });

            const targetColumn = board.columns.find((column) => {
              return column.id === targetColumnId;
            });

            sourceColumn.taskIds = sourceColumn.taskIds.filter((id) => id !== taskId);
            targetColumn.taskIds.push(taskId);
          }

This changes the real board before the user drops the card. If the user only hovers over a column and then leaves, the task may already have moved.

Better temporary drag state:


          type DragState = {
            activeTaskId: string | null;
            sourceColumnId: string | null;
            sourceIndex: number | null;
            targetColumnId: string | null;
            targetIndex: number | null;
          };

          const dragState = reactive<DragState>({
            activeTaskId: null,
            sourceColumnId: null,
            sourceIndex: null,
            targetColumnId: null,
            targetIndex: null
          });

          function startDraggingTask(params: {
            taskId: string;
            columnId: string;
            index: number;
          }) {
            dragState.activeTaskId = params.taskId;
            dragState.sourceColumnId = params.columnId;
            dragState.sourceIndex = params.index;
            dragState.targetColumnId = params.columnId;
            dragState.targetIndex = params.index;
          }

Commit the move only on drop:


          function finishDraggingTask() {
            if (
              !dragState.activeTaskId ||
              !dragState.sourceColumnId ||
              !dragState.targetColumnId ||
              dragState.sourceIndex === null ||
              dragState.targetIndex === null
            ) {
              resetDragState();
              return;
            }

            moveTask({
              taskId: dragState.activeTaskId,
              sourceColumnId: dragState.sourceColumnId,
              targetColumnId: dragState.targetColumnId,
              targetIndex: dragState.targetIndex
            });

            resetDragState();
          }

          function resetDragState() {
            dragState.activeTaskId = null;
            dragState.sourceColumnId = null;
            dragState.sourceIndex = null;
            dragState.targetColumnId = null;
            dragState.targetIndex = null;
          }

Pay attention to: Drag hover is not the same as a confirmed board update. Keep visual drag state separate and update the board only after a valid drop.

3. Moving tasks between columns without updating both source and target correctly

The most important Kanban action is moving a task. It sounds simple, but the logic has several edge cases. The source column must remove the task. The target column must receive the task at the correct position. The task itself may need a new columnId. If the task moves inside the same column, the reorder logic must avoid deleting or duplicating the task. If the target column is empty, the task should still drop correctly.

Beginners often write separate code paths for “move to another column” and “reorder inside column,” but forget one of the edge cases. The result is a board that works for a few moves but breaks after repeated dragging. A more reliable approach is to write one controlled function that receives source and target information and returns a new board state.

Problematic approach:


          function moveTaskToColumn(taskId: string, targetColumnId: string) {
            const targetColumn = board.columns.find((column) => {
              return column.id === targetColumnId;
            });

            targetColumn.taskIds.push(taskId);
          }

This only adds the task to the target column. It does not remove the task from the source column, so the same card can appear in two places.

Better move function:


          type MoveTaskInput = {
            taskId: string;
            sourceColumnId: string;
            targetColumnId: string;
            targetIndex: number;
          };

          function moveTask(input: MoveTaskInput) {
            const sourceColumn = board.columns.find((column) => {
              return column.id === input.sourceColumnId;
            });

            const targetColumn = board.columns.find((column) => {
              return column.id === input.targetColumnId;
            });

            if (!sourceColumn || !targetColumn) {
              return;
            }

            sourceColumn.taskIds = sourceColumn.taskIds.filter((taskId) => {
              return taskId !== input.taskId;
            });

            const safeTargetIndex = Math.min(
              Math.max(input.targetIndex, 0),
              targetColumn.taskIds.length
            );

            targetColumn.taskIds.splice(safeTargetIndex, 0, input.taskId);

            board.tasks[input.taskId] = {
              ...board.tasks[input.taskId],
              columnId: input.targetColumnId
            };
          }

Same-column reorder helper:


          function reorderTaskInColumn(params: {
            columnId: string;
            fromIndex: number;
            toIndex: number;
          }) {
            const column = board.columns.find((item) => {
              return item.id === params.columnId;
            });

            if (!column) {
              return;
            }

            const nextTaskIds = [...column.taskIds];
            const [movedTaskId] = nextTaskIds.splice(params.fromIndex, 1);

            nextTaskIds.splice(params.toIndex, 0, movedTaskId);

            column.taskIds = nextTaskIds;
          }

Pay attention to: Every task move should update the source column, target column, and task relationship consistently. Always test same-column reorder, cross-column movement, empty-column drops, first-position drops, and last-position drops.

4. Saving board data to localStorage without validation or throttling

LocalStorage makes a Kanban project feel realistic because the board does not reset after refresh. But persistence can also introduce bugs. A common mistake is saving the whole board on every tiny drag event or loading saved JSON without checking whether it matches the current data structure. If the saved data is corrupted or belongs to an older version of the app, the board may crash on startup.

Persistence should happen after meaningful changes: task creation, task edit, task deletion, confirmed drag drop, column creation, column rename, and column deletion. It should not happen during every hover state. Loading should include validation and a fallback empty board. This protects the app from broken JSON and makes the board more reliable during development.

Problematic approach:


          watch(
            board,
            () => {
              localStorage.setItem("kanban-board", JSON.stringify(board));
            },
            {
              deep: true
            }
          );

          const savedBoard = JSON.parse(localStorage.getItem("kanban-board"));
          board.columns = savedBoard.columns;
          board.tasks = savedBoard.tasks;

This deep watcher can write too often, especially during drag interactions. The loading logic also assumes saved data is always valid.

Better validation helpers:


          function isKanbanColumn(value: unknown): value is KanbanColumn {
            if (!value || typeof value !== "object") {
              return false;
            }

            const column = value as Record<string, unknown>;

            return (
              typeof column.id === "string" &&
              typeof column.title === "string" &&
              Array.isArray(column.taskIds)
            );
          }

          function isKanbanBoard(value: unknown): value is KanbanBoard {
            if (!value || typeof value !== "object") {
              return false;
            }

            const boardValue = value as Record<string, unknown>;

            return (
              Array.isArray(boardValue.columns) &&
              boardValue.columns.every(isKanbanColumn) &&
              typeof boardValue.tasks === "object" &&
              boardValue.tasks !== null
            );
          }

Safe loading:


          function loadBoardFromStorage(): KanbanBoard {
            try {
              const savedBoard = localStorage.getItem("kanban-board");

              if (!savedBoard) {
                return createDefaultBoard();
              }

              const parsed: unknown = JSON.parse(savedBoard);

              if (!isKanbanBoard(parsed)) {
                return createDefaultBoard();
              }

              return parsed;
            } catch {
              return createDefaultBoard();
            }
          }

Save only after committed changes:


          function saveBoard() {
            localStorage.setItem("kanban-board", JSON.stringify(toRaw(board)));
          }

          function handleDrop() {
            finishDraggingTask();
            saveBoard();
          }

          function handleCreateTask(input: CreateTaskInput) {
            createTaskInColumn(input);
            saveBoard();
          }

Pay attention to: Persist confirmed board changes, not temporary drag hover state. Validate stored data before using it and always provide a safe default board.

5. Filtering tasks by mutating the real board state

Search and filtering are useful in a Kanban board because real boards can contain many tasks. A common mistake is filtering by directly removing tasks from column arrays. This seems to work visually, but it destroys the actual board structure. When the search query is cleared, the original order may be lost or tasks may disappear completely.

Filtering should be derived state, not saved state. The real board should keep all tasks and their original column relationships. The UI can compute visible task IDs based on a search query, priority filter, assignee filter, or overdue filter. This approach keeps the board safe because filters only affect rendering, not the underlying data.

Problematic approach:


          function applySearch(query: string) {
            board.columns.forEach((column) => {
              column.taskIds = column.taskIds.filter((taskId) => {
                const task = board.tasks[taskId];

                return task.title.toLowerCase().includes(query.toLowerCase());
              });
            });
          }

This permanently removes task IDs from columns. Clearing the search cannot reliably restore the original board.

Better filter state:


          const searchQuery = ref("");
          const selectedPriority = ref<"all" | TaskPriority>("all");

          function matchesTaskFilters(task: KanbanTask): boolean {
            const normalizedQuery = searchQuery.value.trim().toLowerCase();

            const matchesSearch =
              !normalizedQuery ||
              task.title.toLowerCase().includes(normalizedQuery) ||
              task.description.toLowerCase().includes(normalizedQuery);

            const matchesPriority =
              selectedPriority.value === "all" ||
              task.priority === selectedPriority.value;

            return matchesSearch && matchesPriority;
          }

Computed visible columns:


          const visibleColumns = computed(() => {
            return board.columns.map((column) => {
              return {
                ...column,
                taskIds: column.taskIds.filter((taskId) => {
                  const task = board.tasks[taskId];

                  return task ? matchesTaskFilters(task) : false;
                })
              };
            });
          });

Template rendering:


          <KanbanColumn
            v-for="column in visibleColumns"
            :key="column.id"
            :column="column"
            :tasks="board.tasks"
          />

Pay attention to: Search and filters should not rewrite the board. Use computed values for visible tasks and keep the saved board structure unchanged.

6. Designing drag-and-drop only for mouse users

Drag-and-drop interfaces often look impressive in a desktop demo, but they can fail for touch users, keyboard users, and users with accessibility needs. A common mistake is building the entire interaction around mouse events and assuming every user can drag precisely. Real boards should provide clear visual feedback, large enough drag handles, and alternative actions for moving tasks.

Even if the first implementation uses the browser Drag and Drop API, you should think about interaction quality. The active card should be visually distinct. Drop zones should highlight when they can accept a task. The board should be usable on smaller screens with horizontal scrolling. For accessibility, provide menu actions such as “Move to In Progress” or “Move to Done,” so a task can be moved without drag gestures.

Problematic approach:


          <article
            class="task-card"
            draggable="true"
            @dragstart="onDragStart(task.id)"
          >
            {{ task.title }}
          </article>

This gives only one interaction path: drag the entire card. It does not provide a keyboard-friendly movement alternative or a clear drag handle.

Better card structure:


          <article
            class="task-card"
            :class="{ 'task-card--dragging': dragState.activeTaskId === task.id }"
          >
            <button
              class="task-card__drag-handle"
              type="button"
              draggable="true"
              @dragstart="startDraggingTask({
                taskId: task.id,
                columnId: task.columnId,
                index: taskIndex
              })"
              aria-label="Drag task"
            >
              ⋮⋮
            </button>

            <div class="task-card__content">
              <h3>{{ task.title }}</h3>
              <p>{{ task.description }}</p>
            </div>

            <TaskMoveMenu
              :task="task"
              :columns="columns"
              @move="moveTaskByMenu"
            />
          </article>

Alternative move action:


          function moveTaskByMenu(params: {
            taskId: string;
            targetColumnId: string;
          }) {
            const targetColumn = board.columns.find((column) => {
              return column.id === params.targetColumnId;
            });

            if (!targetColumn) {
              return;
            }

            moveTask({
              taskId: params.taskId,
              sourceColumnId: board.tasks[params.taskId].columnId,
              targetColumnId: params.targetColumnId,
              targetIndex: targetColumn.taskIds.length
            });

            saveBoard();
          }

Useful visual feedback:


          .task-card--dragging {
            opacity: 0.55;
            transform: rotate(1deg);
          }

          .kanban-column--drop-target {
            outline: 2px dashed #6366f1;
            outline-offset: 4px;
          }

          .task-card__drag-handle {
            cursor: grab;
            min-width: 36px;
            min-height: 36px;
          }

Pay attention to: Drag-and-drop should not be the only way to move tasks. Add clear visual feedback, touch-friendly controls, and non-drag alternatives for better usability.

After completing this project, you will have an advanced Vue.js portfolio project that demonstrates complex state management, interactive UI behavior, drag-and-drop logic, reusable component architecture, and realistic workflow design. This project is valuable because Kanban boards are common in professional tools and require much more than simple rendering. They test your ability to model data, coordinate user interactions, preserve state integrity, and build an interface that remains stable even after many repeated actions.

Reference Implementations Worth Studying

Advanced Vue 3, validation, and native drag API reference:
mohitkumartoshniwal - Kanban Board

This is the strongest direct reference for the project because it matches the advanced Vue direction closely. The repository builds a Kanban board with Vue 3, Composition API, TypeScript, Tailwind CSS, VueUse, VeeValidate, Zod, and the browser Drag and Drop API. It supports CRUD operations for columns and tasks, validates forms, allows dragging both columns and tasks, and persists board data in localStorage.

Pay particular attention to:

  • How Vue 3 and the Composition API structure board behavior without turning one component into a large file.
  • How TypeScript improves confidence when working with columns, tasks, drag metadata, and form inputs.
  • How VeeValidate and Zod make task and column forms safer than manual input checks.
  • How the native browser Drag and Drop API is used for both task movement and column movement.
  • How localStorage persistence makes the board feel like a real productivity tool instead of a temporary demo.

Use this repository as the primary implementation reference. It is especially useful for learners who want to combine drag-and-drop, form validation, typed state, and persistent board data in one advanced Vue project.

Pinia and multi-board architecture reference:
raymedina98 - Drag and Drop Boards

This implementation is useful because it expands the concept from one Kanban board into a boards-and-cards management application. It is built with Vue 3, Composition API, Pinia, TypeScript, and Tailwind CSS. The application supports drag-and-drop for managing columns and cards, responsive design, and synchronization of data changes into the browser's localStorage.

When studying the code, focus on:

  • How Pinia centralizes board state and keeps components from owning too much shared logic.
  • How the application separates board list screens from individual board views.
  • How drag-and-drop behavior changes when the project supports more than one board.
  • How Tailwind CSS helps build a responsive workflow interface quickly.
  • What limitations still exist in a learning/demo project before it becomes suitable for professional workflows.

Use this repository as the state-management and architecture comparison point. It helps learners see how a Kanban project can grow from one board into a multi-board system with reusable state and routing patterns.

Vuex, priority, and search-focused reference:
alaa-abdallah1 - Kanban Task Manager

This repository is valuable as a feature-focused Kanban reference. It is built with Vue 3, Vuex, and Vite and supports task creation, editing, deletion, drag-and-drop, real-time search, debounced filtering, task priority, overdue indicators, localStorage persistence, and Tailwind CSS styling.

While reviewing this project, examine:

  • How Vuex is used to manage tasks and interactions in a centralized way.
  • How task priority levels make the board more realistic than a plain title-only card list.
  • How debounced search improves usability when a board contains many tasks.
  • How overdue indicators can be derived from due dates and displayed clearly on task cards.
  • How persistence restores the board after reload without requiring a backend.

Use this implementation as the search, priority, and task-detail reference. It is especially helpful if you want your Kanban Board to feel like a practical task manager rather than only a drag-and-drop layout exercise.

© 2026 ReadyToDev.Pro. All rights reserved.

Methodology

Privacy Policy

Terms & Conditions