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:
npx -y vui-registry-cli-v1@latest add kanban-boardSource Code
'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
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
'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
'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
'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
'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
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: latestlucide-react: latest
Props
Component property reference.
| Name | Type | Default | Description |
|---|---|---|---|
| className | string | undefined | Optional class name for the board container. |
| initialData | Column[] | demo columns | Initial column and task data for the board. |
| onTaskAdd | (columnId: string, task: Task) => void | undefined | Callback fired when a new task is created. |
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.