Menu

Kanban Board

A draggable Kanban board with optional task cover images, assignee avatars, and an add-task modal that supports image upload. Tasks reorder within columns using Framer Motion — built for sprint boards and team dashboards.

Preview

Live interactive preview.

Installation

Add this component to your project using the CLI:

terminal
npx -y vui-registry-cli-v1@latest add kanban-board

Source Code

add-task-modal.tsx
'use client'

import { useRef, useState } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import { X, ImagePlus } from 'lucide-react'
import type { TaskTag } from './types'

type AddTaskModalProps = {
  isOpen: boolean
  onClose: () => void
  onSubmit: (data: { content: string; tag: TaskTag; image?: string }) => void
}

export function AddTaskModal({ isOpen, onClose, onSubmit }: AddTaskModalProps) {
  const [content, setContent] = useState('')
  const [tag, setTag] = useState<TaskTag>('Design')
  const [imagePreview, setImagePreview] = useState<string | null>(null)
  const fileRef = useRef<HTMLInputElement>(null)

  const reset = () => {
    setContent('')
    setTag('Design')
    setImagePreview(null)
    if (fileRef.current) fileRef.current.value = ''
  }

  const handleClose = () => {
    reset()
    onClose()
  }

  const handleImageChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    const file = e.target.files?.[0]
    if (!file) return
    const reader = new FileReader()
    reader.onload = () => setImagePreview(reader.result as string)
    reader.readAsDataURL(file)
  }

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault()
    if (!content.trim()) return
    onSubmit({ content: content.trim(), tag, image: imagePreview ?? undefined })
    reset()
  }

  return (
    <AnimatePresence>
      {isOpen && (
        <div className="fixed inset-0 z-50 flex items-center justify-center p-4">
          <motion.div
            initial={{ opacity: 0 }}
            animate={{ opacity: 1 }}
            exit={{ opacity: 0 }}
            onClick={handleClose}
            className="absolute inset-0 bg-black/40 backdrop-blur-sm"
          />
          <motion.div
            initial={{ opacity: 0, scale: 0.95, y: 20 }}
            animate={{ opacity: 1, scale: 1, y: 0 }}
            exit={{ opacity: 0, scale: 0.95, y: 20 }}
            className="w-full max-w-sm bg-white dark:bg-neutral-900 rounded-2xl p-6 relative z-10 border border-neutral-200 dark:border-white/10"
          >
            <div className="flex items-center justify-between mb-6">
              <h3 className="text-lg font-bold text-neutral-900 dark:text-white">Add New Task</h3>
              <button type="button" onClick={handleClose} className="p-1 hover:bg-neutral-100 dark:hover:bg-neutral-800 rounded-full">
                <X size={20} className="text-neutral-500" />
              </button>
            </div>

            <form onSubmit={handleSubmit} className="space-y-4">
              <div>
                <label className="block text-xs font-semibold text-neutral-500 uppercase tracking-wider mb-1.5">
                  Task Description
                </label>
                <input
                  autoFocus
                  type="text"
                  value={content}
                  onChange={(e) => setContent(e.target.value)}
                  placeholder="What needs to be done?"
                  className="w-full bg-neutral-50 dark:bg-neutral-800 border border-neutral-200 dark:border-white/10 rounded-xl px-4 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500/20 focus:border-indigo-500 text-neutral-900 dark:text-white"
                />
              </div>

              <div>
                <label className="block text-xs font-semibold text-neutral-500 uppercase tracking-wider mb-1.5">Tag</label>
                <div className="flex gap-2">
                  {(['Design', 'Dev', 'Marketing'] as const).map((t) => (
                    <button
                      key={t}
                      type="button"
                      onClick={() => setTag(t)}
                      className={`px-3 py-1.5 rounded-lg text-xs font-medium border transition-all ${
                        tag === t
                          ? 'bg-neutral-900 dark:bg-white text-white dark:text-neutral-900 border-transparent'
                          : 'bg-transparent border-neutral-200 dark:border-white/10 text-neutral-600 dark:text-neutral-400'
                      }`}
                    >
                      {t}
                    </button>
                  ))}
                </div>
              </div>

              <div>
                <label className="block text-xs font-semibold text-neutral-500 uppercase tracking-wider mb-1.5">
                  Cover Image (optional)
                </label>
                <input ref={fileRef} type="file" accept="image/*" onChange={handleImageChange} className="hidden" />
                <button
                  type="button"
                  onClick={() => fileRef.current?.click()}
                  className="w-full flex items-center justify-center gap-2 py-2.5 rounded-xl border border-dashed border-neutral-300 dark:border-white/15 text-sm text-neutral-500 hover:text-neutral-800 dark:hover:text-neutral-200 hover:border-neutral-400 transition-colors"
                >
                  <ImagePlus size={16} />
                  {imagePreview ? 'Change image' : 'Upload image'}
                </button>
                {imagePreview && (
                  <div className="mt-2 h-16 w-full overflow-hidden rounded-lg border border-neutral-200 dark:border-white/10">
                    <img src={imagePreview} alt="Preview" className="w-full h-full object-cover" />
                  </div>
                )}
              </div>

              <button
                type="submit"
                disabled={!content.trim()}
                className="w-full bg-neutral-900 dark:bg-white text-white dark:text-neutral-900 font-semibold py-2.5 rounded-xl hover:opacity-90 disabled:opacity-50 disabled:cursor-not-allowed"
              >
                Create Task
              </button>
            </form>
          </motion.div>
        </div>
      )}
    </AnimatePresence>
  )
}

constants.ts

constants.ts
import type { Column, TaskTag } from './types'

export const TAG_STYLES: Record<TaskTag, string> = {
  Design: 'bg-pink-100 text-pink-600 dark:bg-pink-500/10 dark:text-pink-400',
  Dev: 'bg-blue-100 text-blue-600 dark:bg-blue-500/10 dark:text-blue-400',
  Marketing: 'bg-orange-100 text-orange-600 dark:bg-orange-500/10 dark:text-orange-400',
}

export const DEFAULT_AVATAR =
  'https://i.postimg.cc/3xgQH76g/Whats-App-Image-2026-02-19-at-8-23-43-PM.jpg'

export const INITIAL_COLUMNS: Column[] = [
  {
    id: 'todo',
    title: 'To Do',
    tasks: [
      { id: '1', content: 'Design System', tag: 'Design', date: 'Oct 24', assignee: 'Vikas', assigneeAvatar: DEFAULT_AVATAR, comments: 2 },
      { id: '2', content: 'User Research', tag: 'Marketing', date: 'Oct 25', assignee: 'Sarah', comments: 1 },
    ],
  },
  {
    id: 'in-progress',
    title: 'In Progress',
    tasks: [
      {
        id: '3',
        content: 'Homepage Hero',
        tag: 'Dev',
        date: 'Oct 22',
        assignee: 'Alex',
        image: 'https://images.unsplash.com/photo-1460925895917-afdab827c52f?w=300&auto=format&fit=crop',
        attachments: 1,
      },
    ],
  },
  {
    id: 'done',
    title: 'Done',
    tasks: [{ id: '4', content: 'Analytics Setup', tag: 'Dev', date: 'Oct 20', assignee: 'James' }],
  },
]

Source Code

kanban-board.tsx
'use client'

import { useState } from 'react'
import { AnimatePresence } from 'framer-motion'
import { cn } from '@/lib/utils'
import { INITIAL_COLUMNS, DEFAULT_AVATAR } from './constants'
import { KanbanToast } from './toast'
import { KanbanColumn } from './kanban-column'
import { AddTaskModal } from './add-task-modal'
import type { KanbanBoardProps, Task, TaskTag } from './types'

export type { Task, KanbanBoardProps } from './types'

export default function KanbanBoard({
  className,
  initialData = INITIAL_COLUMNS,
  onTaskAdd,
}: KanbanBoardProps) {
  const [columns, setColumns] = useState(initialData)
  const [isModalOpen, setIsModalOpen] = useState(false)
  const [activeColumnId, setActiveColumnId] = useState<string | null>(null)
  const [toast, setToast] = useState<string | null>(null)

  const showToast = (msg: string) => {
    setToast(msg)
    setTimeout(() => setToast(null), 2000)
  }

  const openAddTaskModal = (columnId: string) => {
    setActiveColumnId(columnId)
    setIsModalOpen(true)
  }

  const handleAddTask = (data: { content: string; tag: TaskTag; image?: string }) => {
    if (!activeColumnId) return

    const newTask: Task = {
      id: Math.random().toString(36).slice(2, 11),
      content: data.content,
      tag: data.tag,
      date: 'Today',
      assignee: 'You',
      assigneeAvatar: DEFAULT_AVATAR,
      image: data.image,
    }

    setColumns(
      columns.map((col) =>
        col.id === activeColumnId ? { ...col, tasks: [...col.tasks, newTask] } : col
      )
    )
    onTaskAdd?.(activeColumnId, newTask)
    setIsModalOpen(false)
    showToast('Task added successfully')
  }

  return (
    <div className={cn('w-full min-h-[600px] bg-neutral-50 dark:bg-neutral-950 p-8 overflow-x-auto relative', className)}>
      <AnimatePresence>{toast && <KanbanToast message={toast} />}</AnimatePresence>

      <div className="flex items-start justify-center gap-6 min-w-max">
        {columns.map((column) => (
          <KanbanColumn
            key={column.id}
            column={column}
            onAddTask={openAddTaskModal}
            onReorder={(newTasks) => {
              setColumns(columns.map((c) => (c.id === column.id ? { ...c, tasks: newTasks } : c)))
            }}
          />
        ))}
      </div>

      <AddTaskModal isOpen={isModalOpen} onClose={() => setIsModalOpen(false)} onSubmit={handleAddTask} />
    </div>
  )
}

Source Code

kanban-column.tsx
'use client'

import { Reorder } from 'framer-motion'
import { MoreHorizontal, Plus } from 'lucide-react'
import { TaskCard } from './task-card'
import type { Column } from './types'

type KanbanColumnProps = {
  column: Column
  onReorder: (tasks: Column['tasks']) => void
  onAddTask: (columnId: string) => void
}

export function KanbanColumn({ column, onReorder, onAddTask }: KanbanColumnProps) {
  return (
    <div className="w-80 shrink-0">
      <div className="flex items-center justify-between mb-4 px-1">
        <div className="flex items-center gap-2">
          <h3 className="font-semibold text-neutral-900 dark:text-white">{column.title}</h3>
          <span className="text-[10px] font-bold px-1.5 py-0.5 rounded-md bg-neutral-200/80 dark:bg-white/10 text-neutral-500">
            {column.tasks.length}
          </span>
        </div>
        <button type="button" className="p-1 hover:bg-neutral-200 dark:hover:bg-neutral-800 rounded">
          <MoreHorizontal size={16} className="text-neutral-500" />
        </button>
      </div>

      <Reorder.Group axis="y" values={column.tasks} onReorder={onReorder} className="space-y-3">
        {column.tasks.map((task) => (
          <TaskCard key={task.id} task={task} />
        ))}
      </Reorder.Group>

      <button
        type="button"
        onClick={() => onAddTask(column.id)}
        className="w-full mt-3 py-2 flex items-center justify-center gap-2 text-sm text-neutral-500 hover:text-neutral-900 dark:hover:text-white hover:bg-neutral-200 dark:hover:bg-neutral-800/50 rounded-lg transition-colors border border-dashed border-neutral-300 dark:border-white/10"
      >
        <Plus size={16} />
        Add Task
      </button>
    </div>
  )
}

Source Code

task-card.tsx
'use client'

import { Reorder } from 'framer-motion'
import { Calendar, Paperclip, MessageSquare } from 'lucide-react'
import { TAG_STYLES } from './constants'
import type { Task } from './types'

type TaskCardProps = {
  task: Task
}

export function TaskCard({ task }: TaskCardProps) {
  return (
    <Reorder.Item
      value={task}
      className="bg-white dark:bg-neutral-900 rounded-xl border border-neutral-200 dark:border-white/5 cursor-grab active:cursor-grabbing hover:border-neutral-300 dark:hover:border-white/15 transition-colors overflow-hidden"
    >
      {task.image && (
        <div className="px-3 pt-3">
          <div className="relative h-16 w-full overflow-hidden rounded-lg border border-neutral-100 dark:border-white/5">
            <img src={task.image} alt="" className="w-full h-full object-cover" />
          </div>
        </div>
      )}

      <div className="p-3">
        <span className={`text-[10px] font-bold px-2 py-1 rounded-md uppercase tracking-wider ${TAG_STYLES[task.tag]}`}>
          {task.tag}
        </span>
        <p className="text-sm font-medium text-neutral-900 dark:text-neutral-200 mt-2">{task.content}</p>

        <div className="mt-3 flex items-center justify-between text-neutral-400">
          <div className="flex items-center gap-3">
            {task.date && (
              <span className="flex items-center gap-1 text-xs">
                <Calendar size={12} />
                {task.date}
              </span>
            )}
            {task.attachments ? (
              <span className="flex items-center gap-1 text-xs">
                <Paperclip size={12} />
                {task.attachments}
              </span>
            ) : null}
            {task.comments ? (
              <span className="flex items-center gap-1 text-xs">
                <MessageSquare size={12} />
                {task.comments}
              </span>
            ) : null}
          </div>
          {task.assigneeAvatar && (
            <img
              src={task.assigneeAvatar}
              alt={task.assignee}
              className="w-6 h-6 rounded-full object-cover ring-2 ring-white dark:ring-neutral-900"
            />
          )}
        </div>
      </div>
    </Reorder.Item>
  )
}

Source Code

toast.tsx
'use client'

import { motion } from 'framer-motion'
import { CheckCircle2 } from 'lucide-react'

export function KanbanToast({ message }: { message: string }) {
  return (
    <motion.div
      initial={{ opacity: 0, y: 50, scale: 0.9 }}
      animate={{ opacity: 1, y: 0, scale: 1 }}
      exit={{ opacity: 0, y: 20, scale: 0.9 }}
      className="fixed bottom-6 left-1/2 -translate-x-1/2 bg-neutral-900 dark:bg-white text-white dark:text-neutral-900 px-6 py-3 rounded-full shadow-xl flex items-center gap-3 z-50 font-medium text-sm"
    >
      <CheckCircle2 size={18} className="text-emerald-500" />
      {message}
    </motion.div>
  )
}

types.ts

types.ts
export type TaskTag = 'Design' | 'Dev' | 'Marketing'

export type Task = {
  id: string
  content: string
  tag: TaskTag
  date?: string
  assignee?: string
  assigneeAvatar?: string
  image?: string
  attachments?: number
  comments?: number
}

export type Column = {
  id: string
  title: string
  tasks: Task[]
}

export type KanbanBoardProps = {
  className?: string
  initialData?: Column[]
  onTaskAdd?: (columnId: string, task: Task) => void
}

Dependencies

  • framer-motion: latest
  • lucide-react: latest

Props

Component property reference.

NameTypeDefaultDescription
classNamestringundefinedOptional class name for the board container.
initialDataColumn[]demo columnsInitial column and task data for the board.
onTaskAdd(columnId: string, task: Task) => voidundefinedCallback fired when a new task is created.
Context Worth Keeping In Orbit

Most components here are inspired by outstanding libraries and creators in the ecosystem. I don’t claim to be the original author — this is my space for learning, rebuilding, and understanding great work at a deeper level.

I’m still a student of the craft, constantly studying the best and translating what I learn through my own perspective. Every piece reflects curiosity, respect for the community, and small creative touches that feel true to me.

I’ve done my best to credit inspirations properly. If anything is missing or inaccurate, I truly appreciate a message so it can be corrected with care.