Velocity UI
Loading…
Menu

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

Source 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: latest
  • lucide-react: latest