Kanban Board
A draggable Kanban board with columns and tasks using Framer Motion reorder.
Installation
Add this component to your project using the CLI:
terminal
npx vui-registry-cli-v1 add kanban-boardSource Code
kanban-board.tsx
'use client'
import React, { useState } from 'react'
import { motion, Reorder, AnimatePresence } from 'framer-motion'
import { MoreHorizontal, Plus, X, Calendar, User, CheckCircle2 } from 'lucide-react'
interface Task {
id: string
content: string
tag: 'Design' | 'Dev' | 'Marketing'
date?: string
assignee?: string
}
interface Column {
id: string
title: string
tasks: Task[]
}
const initialColumns: Column[] = [
{
id: 'todo',
title: 'To Do',
tasks: [
{ id: '1', content: 'Design System', tag: 'Design', date: 'Oct 24' },
{ id: '2', content: 'User Research', tag: 'Marketing', date: 'Oct 25' },
],
},
{
id: 'in-progress',
title: 'In Progress',
tasks: [
{ id: '3', content: 'Homepage Hero', tag: 'Dev', date: 'Oct 22' },
],
},
{
id: 'done',
title: 'Done',
tasks: [
{ id: '4', content: 'Analytics Setup', tag: 'Dev', date: 'Oct 20' },
],
},
]
// Simple Toast Component
const Toast = ({ message, onClose }: { message: string; onClose: () => void }) => (
<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"
>
<CheckCircle2 size={18} className="text-green-500" />
{message}
</motion.div>
)
export default function KanbanBoard() {
const [columns, setColumns] = useState(initialColumns)
const [isModalOpen, setIsModalOpen] = useState(false)
const [activeColumnId, setActiveColumnId] = useState<string | null>(null)
const [newTaskContent, setNewTaskContent] = useState('')
const [newTaskTag, setNewTaskTag] = useState<'Design' | 'Dev' | 'Marketing'>('Design')
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 = (e: React.FormEvent) => {
e.preventDefault()
if (!newTaskContent.trim() || !activeColumnId) return
const newTask: Task = {
id: Math.random().toString(36).substr(2, 9),
content: newTaskContent,
tag: newTaskTag,
date: 'Today'
}
setColumns(columns.map(col =>
col.id === activeColumnId
? { ...col, tasks: [...col.tasks, newTask] }
: col
))
setNewTaskContent('')
setIsModalOpen(false)
showToast('Task added successfully')
}
return (
<div className="w-full min-h-[600px] bg-neutral-50 dark:bg-neutral-950 p-8 overflow-x-auto relative">
<AnimatePresence>
{toast && <Toast message={toast} onClose={() => setToast(null)} />}
</AnimatePresence>
<div className="flex items-start justify-center gap-6 min-w-max">
{columns.map((column) => (
<div key={column.id} className="w-80 shrink-0">
<div className="flex items-center justify-between mb-4 px-1">
<h3 className="font-semibold text-neutral-900 dark:text-white">{column.title}</h3>
<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={(newTasks) => {
const newCols = columns.map((c) =>
c.id === column.id ? { ...c, tasks: newTasks } : c
)
setColumns(newCols)
}}
className="space-y-3"
>
{column.tasks.map((task) => (
<Reorder.Item
key={task.id}
value={task}
className="bg-white dark:bg-neutral-900 p-4 rounded-xl shadow-sm border border-neutral-200 dark:border-white/5 cursor-grab active:cursor-grabbing hover:shadow-md transition-shadow"
>
<div className="flex justify-between items-start mb-2">
<span className={`text-[10px] font-bold px-2 py-1 rounded-md uppercase tracking-wider ${
task.tag === 'Design' ? 'bg-pink-100 text-pink-600 dark:bg-pink-500/10 dark:text-pink-400' :
task.tag === 'Dev' ? 'bg-blue-100 text-blue-600 dark:bg-blue-500/10 dark:text-blue-400' :
'bg-orange-100 text-orange-600 dark:bg-orange-500/10 dark:text-orange-400'
}`}>
{task.tag}
</span>
</div>
<p className="text-sm font-medium text-neutral-900 dark:text-neutral-200">{task.content}</p>
{task.date && (
<div className="mt-3 flex items-center gap-1 text-xs text-neutral-400">
<Calendar size={12} />
{task.date}
</div>
)}
</Reorder.Item>
))}
</Reorder.Group>
<button
onClick={() => openAddTaskModal(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>
))}
</div>
{/* Add Task Modal */}
<AnimatePresence>
{isModalOpen && (
<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={() => setIsModalOpen(false)}
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 shadow-2xl 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 onClick={() => setIsModalOpen(false)} className="p-1 hover:bg-neutral-100 dark:hover:bg-neutral-800 rounded-full transition-colors">
<X size={20} className="text-neutral-500" />
</button>
</div>
<form onSubmit={handleAddTask} 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={newTaskContent}
onChange={(e) => setNewTaskContent(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 transition-all 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((tag) => (
<button
key={tag}
type="button"
onClick={() => setNewTaskTag(tag)}
className={`px-3 py-1.5 rounded-lg text-xs font-medium border transition-all ${
newTaskTag === tag
? '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 hover:border-neutral-300'
}`}
>
{tag}
</button>
))}
</div>
</div>
<button
type="submit"
disabled={!newTaskContent.trim()}
className="w-full bg-neutral-900 dark:bg-white text-white dark:text-neutral-900 font-semibold py-2.5 rounded-xl mt-2 hover:opacity-90 active:scale-[0.98] transition-all disabled:opacity-50 disabled:cursor-not-allowed"
>
Create Task
</button>
</form>
</motion.div>
</div>
)}
</AnimatePresence>
</div>
)
}
Dependencies
framer-motion: latestlucide-react: latest

