Components
A fully interactive, multi-column Kanban board featuring native HTML5 drag and drop, inline task creation, rich card metadata, and a clean minimalist aesthetic.

import React, { useState } from 'react';
import { MoreHorizontal, Calendar, GripVertical, CheckCircle2, MessageSquare, Paperclip, Plus, Trash2 } from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion';
// --- Types & Initial Data ---
type Priority = 'Low' | 'Medium' | 'High';
interface Tag {
label: string;
dotColor: string;
}
interface CardData {
id: string;
title: string;
description?: string;
tags?: Tag[];
priority?: Priority;
date?: string;
avatars?: string[];
tasksCompleted?: number;
tasksTotal?: number;
comments?: number;
attachments?: number;
coverImage?: string;
}
interface ColumnData {
id: string;
title: string;
cards: CardData[];
}
const INITIAL_BOARD: ColumnData[] = [
{
id: 'col-1',
title: 'To Do',
cards: [
{
id: 'c-1',
title: 'Design System Update',
description: 'Audit existing components and create new variants for dark mode.',
tags: [{ label: 'Design', dotColor: 'bg-purple-500' }],
priority: 'Medium',
date: 'Oct 15',
comments: 3,
attachments: 2,
avatars: ["https://i.pravatar.cc/150?u=a042581f4e29026024d"]
},
{
id: 'c-2',
title: 'Landing Page Hero Iteration',
coverImage: 'https://images.unsplash.com/photo-1618005182384-a83a8bd57fbe?q=80&w=2564&auto=format&fit=crop',
tags: [{ label: 'Marketing', dotColor: 'bg-blue-500' }],
priority: 'High',
comments: 12,
avatars: ["https://i.pravatar.cc/150?u=1", "https://i.pravatar.cc/150?u=2"]
}
]
},
{
id: 'col-2',
title: 'In Progress',
cards: [
{
id: 'c-3',
title: 'Fix Mobile Navigation Bug',
description: 'The hamburger menu doesn\'t close automatically when tapping outside the container.',
tags: [{ label: 'Bug', dotColor: 'bg-orange-500' }],
priority: 'High',
date: 'Oct 12',
tasksCompleted: 2,
tasksTotal: 5,
avatars: ["https://i.pravatar.cc/150?u=3"]
}
]
},
{
id: 'col-3',
title: 'Done',
cards: [
{
id: 'c-4',
title: 'Q3 Financial Report',
tags: [{ label: 'Finance', dotColor: 'bg-green-500' }],
priority: 'Low',
date: 'Oct 01',
attachments: 4,
avatars: ["https://i.pravatar.cc/150?u=4"]
}
]
}
];
// --- Main Demo Component ---
export default function Demo() {
const [board, setBoard] = useState<ColumnData[]>(INITIAL_BOARD);
const [draggingCard, setDraggingCard] = useState<{ card: CardData; sourceColId: string } | null>(null);
// --- Drag and Drop Handlers ---
const handleDragStart = (e: React.DragEvent, card: CardData, colId: string) => {
setDraggingCard({ card, sourceColId: colId });
e.dataTransfer.effectAllowed = 'move';
setTimeout(() => {
if (e.target instanceof HTMLElement) e.target.classList.add('opacity-40');
}, 0);
};
const handleDragEnd = (e: React.DragEvent) => {
if (e.target instanceof HTMLElement) e.target.classList.remove('opacity-40');
setDraggingCard(null);
};
const handleDrop = (e: React.DragEvent, targetColId: string) => {
e.preventDefault();
if (!draggingCard) return;
if (draggingCard.sourceColId === targetColId) return;
setBoard(prev => {
const newBoard = [...prev];
const sourceColIndex = newBoard.findIndex(c => c.id === draggingCard.sourceColId);
const targetColIndex = newBoard.findIndex(c => c.id === targetColId);
// Remove from source
newBoard[sourceColIndex] = {
...newBoard[sourceColIndex],
cards: newBoard[sourceColIndex].cards.filter(c => c.id !== draggingCard.card.id)
};
// Add to target
newBoard[targetColIndex] = {
...newBoard[targetColIndex],
cards: [...newBoard[targetColIndex].cards, draggingCard.card]
};
return newBoard;
});
};
// --- Interactive Actions ---
const handleAddCard = (colId: string, title: string) => {
const newCard: CardData = {
id: `c-${Date.now()}`,
title,
tags: [{ label: 'New', dotColor: 'bg-blue-500' }],
date: new Date().toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
};
setBoard(prev => prev.map(col => {
if (col.id === colId) {
return { ...col, cards: [...col.cards, newCard] };
}
return col;
}));
};
const handleDeleteCard = (colId: string, cardId: string) => {
setBoard(prev => prev.map(col => {
if (col.id === colId) {
return { ...col, cards: col.cards.filter(c => c.id !== cardId) };
}
return col;
}));
};
return (
<div className="w-full min-h-screen bg-gray-50 dark:bg-[#111113] p-8 md:p-12 overflow-x-auto transition-colors duration-300 flex font-sans">
<div className="flex items-start gap-6 mx-auto">
{board.map((col) => (
<KanbanColumn
key={col.id}
col={col}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onDrop={handleDrop}
onAddCard={handleAddCard}
onDeleteCard={handleDeleteCard}
/>
))}
</div>
</div>
);
}
// --- Column Component ---
function KanbanColumn({ col, onDragStart, onDragEnd, onDrop, onAddCard, onDeleteCard }: any) {
const [isAdding, setIsAdding] = useState(false);
const [newTaskTitle, setNewTaskTitle] = useState("");
const submitNewCard = () => {
if (newTaskTitle.trim()) {
onAddCard(col.id, newTaskTitle);
}
setNewTaskTitle("");
setIsAdding(false);
};
return (
<div
className="flex flex-col w-full min-w-[320px] max-w-[320px]"
onDragOver={(e) => { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; }}
onDrop={(e) => onDrop(e, col.id)}
>
{/* Column Header */}
<div className="flex items-center justify-between px-1 mb-4">
<h3 className="text-sm font-bold text-gray-900 dark:text-gray-100 flex items-center gap-2">
{col.title}
<span className="text-xs font-semibold bg-gray-200 dark:bg-[#25262b] text-gray-600 dark:text-gray-400 px-2 py-0.5 rounded-full">
{col.cards.length}
</span>
</h3>
<button className="text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 transition-colors">
<MoreHorizontal size={20} />
</button>
</div>
{/* Cards List */}
<div className="flex flex-col gap-4 min-h-[100px] rounded-2xl transition-colors">
<AnimatePresence>
{col.cards.map((card: CardData) => (
<motion.div
key={card.id}
layout
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95 }}
draggable
onDragStart={(e: any) => onDragStart(e, card, col.id)}
onDragEnd={onDragEnd}
>
<KanbanCard card={card} onDelete={() => onDeleteCard(col.id, card.id)} />
</motion.div>
))}
</AnimatePresence>
{/* Empty State Visual Hint */}
{col.cards.length === 0 && !isAdding && (
<div className="h-24 rounded-2xl border-2 border-dashed border-gray-200 dark:border-gray-800 flex items-center justify-center text-sm text-gray-400">
Drop cards here
</div>
)}
{/* Add Card Inline Input */}
{isAdding ? (
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} className="mt-2">
<input
autoFocus
type="text"
placeholder="What needs to be done?"
className="w-full p-3 text-sm rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-[#1a1b1e] text-gray-900 dark:text-gray-100 outline-none focus:ring-2 focus:ring-blue-500 shadow-sm transition-all"
value={newTaskTitle}
onChange={e => setNewTaskTitle(e.target.value)}
onKeyDown={e => {
if (e.key === 'Enter') submitNewCard();
if (e.key === 'Escape') setIsAdding(false);
}}
onBlur={() => {
if (newTaskTitle.trim()) submitNewCard();
else setIsAdding(false);
}}
/>
</motion.div>
) : (
<button
onClick={() => setIsAdding(true)}
className="mt-2 flex items-center justify-center gap-2 w-full py-3 rounded-xl border-2 border-dashed border-gray-200 dark:border-gray-800/60 text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:hover:text-gray-300 dark:hover:border-gray-700 transition-colors text-sm font-semibold"
>
<Plus size={16} /> Add Task
</button>
)}
</div>
</div>
);
}
// --- Card Component ---
function KanbanCard({ card, onDelete }: { card: CardData, onDelete: () => void }) {
const [showMenu, setShowMenu] = useState(false);
const getPriorityColor = (p?: Priority) => {
switch(p) {
case 'High': return 'text-red-600 bg-red-50 dark:bg-red-500/10 dark:text-red-400';
case 'Medium': return 'text-orange-600 bg-orange-50 dark:bg-orange-500/10 dark:text-orange-400';
case 'Low': return 'text-blue-600 bg-blue-50 dark:bg-blue-500/10 dark:text-blue-400';
default: return 'text-gray-600 bg-gray-50 dark:bg-gray-800 dark:text-gray-400';
}
};
return (
<div
className="group relative flex flex-col w-full bg-white dark:bg-[#1a1b1e] rounded-2xl border border-gray-200 dark:border-gray-800/80 shadow-sm hover:shadow-md transition-all duration-200 cursor-grab active:cursor-grabbing overflow-visible"
>
{/* Optional Cover Image */}
{card.coverImage && (
<div className="w-full h-32 overflow-hidden rounded-t-2xl border-b border-gray-100 dark:border-gray-800">
<img src={card.coverImage} alt="Cover" className="w-full h-full object-cover transition-transform duration-500 group-hover:scale-105" />
</div>
)}
<div className="flex flex-col gap-4 p-5">
{/* Subtle Drag Handle on Hover */}
<div className="absolute top-1/2 -left-3 -translate-y-1/2 opacity-0 group-hover:opacity-100 transition-opacity text-gray-300 dark:text-gray-600">
<GripVertical size={16} />
</div>
{/* Header: Tags, Priority & Action Menu */}
<div className="flex items-center justify-between">
<div className="flex flex-wrap gap-2">
{card.tags?.map((tag, idx) => (
<span
key={idx}
className="inline-flex items-center gap-1.5 px-2 py-1 rounded-md text-[11px] font-semibold tracking-wide bg-gray-100 dark:bg-[#25262b] text-gray-700 dark:text-gray-300 transition-colors uppercase"
>
<span className={`w-1.5 h-1.5 rounded-full ${tag.dotColor}`} />
{tag.label}
</span>
))}
{card.priority && (
<span className={`px-2 py-1 rounded-md text-[11px] font-bold tracking-wide uppercase ${getPriorityColor(card.priority)}`}>
{card.priority}
</span>
)}
</div>
{/* Card Actions Menu */}
<div className="relative">
<button
onClick={(e) => { e.stopPropagation(); setShowMenu(!showMenu); }}
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 transition-colors p-1"
>
<MoreHorizontal size={18} />
</button>
{showMenu && (
<>
<div className="fixed inset-0 z-10" onClick={() => setShowMenu(false)} />
<div className="absolute right-0 mt-1 w-32 bg-white dark:bg-[#25262b] border border-gray-100 dark:border-gray-800 rounded-lg shadow-xl overflow-hidden z-20 py-1">
<button
onClick={() => { onDelete(); setShowMenu(false); }}
className="w-full text-left px-3 py-2 text-sm font-medium text-red-600 hover:bg-red-50 dark:hover:bg-red-500/10 transition-colors flex items-center gap-2"
>
<Trash2 size={14} /> Delete
</button>
</div>
</>
)}
</div>
</div>
{/* Body: Title & Description */}
<div className="flex flex-col gap-1.5 mt-1">
<h4 className="text-[15px] font-bold text-gray-900 dark:text-gray-100 leading-snug">
{card.title}
</h4>
{card.description && (
<p className="text-sm text-gray-500 dark:text-gray-400 line-clamp-2 leading-relaxed">
{card.description}
</p>
)}
</div>
{/* Footer: Meta details & Avatars */}
<div className="flex items-center justify-between mt-2 pt-4 border-t border-gray-100 dark:border-gray-800/60">
<div className="flex items-center gap-3.5 text-xs font-medium text-gray-500 dark:text-gray-400">
{card.date && (
<div className="flex items-center gap-1.5">
<Calendar size={14} className="text-gray-400" />
<span>{card.date}</span>
</div>
)}
{card.tasksTotal !== undefined && card.tasksCompleted !== undefined && (
<div className="flex items-center gap-1.5">
<CheckCircle2 size={14} className={card.tasksCompleted === card.tasksTotal ? "text-green-500" : "text-gray-400"} />
<span>{card.tasksCompleted}/{card.tasksTotal}</span>
</div>
)}
{card.comments !== undefined && card.comments > 0 && (
<div className="flex items-center gap-1.5 hover:text-gray-700 dark:hover:text-gray-300 cursor-pointer transition-colors">
<MessageSquare size={14} className="text-gray-400" />
<span>{card.comments}</span>
</div>
)}
{card.attachments !== undefined && card.attachments > 0 && (
<div className="flex items-center gap-1.5">
<Paperclip size={14} className="text-gray-400" />
<span>{card.attachments}</span>
</div>
)}
</div>
{/* Overlapping Avatars */}
{card.avatars && card.avatars.length > 0 && (
<div className="flex items-center -space-x-2 shrink-0 ml-4">
{card.avatars.map((url, idx) => (
<img
key={idx}
src={url}
alt="Assignee"
className="w-7 h-7 rounded-full border-2 border-white dark:border-[#1a1b1e] object-cover ring-1 ring-gray-100 dark:ring-gray-800"
/>
))}
</div>
)}
</div>
</div>
</div>
);
}