All components

Task Board

Task Board is a React and Tailwind CSS component from HextaUI, licensed MIT. Copy it and paste it into your project.

HextaUI MIT other

What it is

Task Board is a React and Tailwind CSS component from HextaUI, licensed MIT. Copy it and paste it into your project.

How to use it

Open it in the catalogue, press copy, and paste it into your project. The prompt you copy carries the code and the rules that tell your agent to reproduce it without changing anything.

Open in the catalogue

Five free copies a month. No card.

No packages beyond the project baseline

Licence

This component comes from HextaUI and is redistributed under the MIT licence, which permits commercial use. The copyright notice travels with the code.

Task Board

registry/new-york/blocks/tasks/task-board.tsx
"use client";

import {
  Calendar,
  CheckCircle2,
  Circle,
  Clock,
  MoreVertical,
  Plus,
  X,
} from "lucide-react";
import { useMemo, useState } from "react";
import { cn } from "@/lib/utils";
import {
  Avatar,
  AvatarFallback,
  AvatarImage,
} from "@/registry/new-york/ui/avatar";
import { Badge } from "@/registry/new-york/ui/badge";
import { Button } from "@/registry/new-york/ui/button";
import {
  Card,
  CardContent,
  CardDescription,
  CardHeader,
  CardTitle,
} from "@/registry/new-york/ui/card";
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuSeparator,
  DropdownMenuTrigger,
} from "@/registry/new-york/ui/dropdown-menu";
import type { Task, TaskPriority, TaskStatus } from "./task-list";

export interface TaskBoardProps {
  tasks?: Task[];
  columns?: Array<{
    id: TaskStatus;
    title: string;
    limit?: number;
  }>;
  onTaskMove?: (
    taskId: string,
    fromStatus: TaskStatus,
    toStatus: TaskStatus
  ) => Promise<void>;
  onTaskSelect?: (taskId: string) => void;
  onTaskCreate?: (status: TaskStatus) => void;
  onTaskUpdate?: (taskId: string, updates: Partial<Task>) => Promise<void>;
  onTaskDelete?: (taskId: string) => Promise<void>;
  className?: string;
  showColumnLimits?: boolean;
}

function formatDate(date: Date): string {
  const now = new Date();
  const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
  const dateOnly = new Date(
    date.getFullYear(),
    date.getMonth(),
    date.getDate()
  );

  if (dateOnly.getTime() === today.getTime()) {
    return "Today";
  }

  const month = date.toLocaleString("en-US", { month: "short" });
  const day = date.getDate();
  return `${month} ${day}`;
}

function getPriorityColor(priority: TaskPriority): string {
  switch (priority) {
    case "urgent":
      return "bg-red-500";
    case "high":
      return "bg-orange-500";
    case "medium":
      return "bg-yellow-500";
    default:
      return "bg-gray-500";
  }
}

function isOverdue(date?: Date): boolean {
  if (!date) return false;
  const now = new Date();
  now.setHours(0, 0, 0, 0);
  const dueDate = new Date(date);
  dueDate.setHours(0, 0, 0, 0);
  return dueDate < now;
}

interface TaskCardProps {
  task: Task;
  onTaskClick: (taskId: string) => void;
  onStatusChange: (taskId: string, status: TaskStatus) => void;
  onDelete: (taskId: string) => void;
  isDragging?: boolean;
  onDragStart?: (taskId: string) => void;
  onDragEnd?: () => void;
}

function TaskCard({
  task,
  onTaskClick,
  onStatusChange,
  onDelete,
  isDragging = false,
  onDragStart,
  onDragEnd,
}: TaskCardProps) {
  const overdue = isOverdue(task.dueDate);

  const handleDragStart = (e: React.DragEvent) => {
    e.dataTransfer.effectAllowed = "move";
    e.dataTransfer.setData("text/plain", task.id);
    e.dataTransfer.setData("application/task-status", task.status);
    onDragStart?.(task.id);
  };

  const handleDragEnd = () => {
    onDragEnd?.();
  };

  return (
    <div
      className={cn(
        "group relative flex cursor-move flex-col gap-2 rounded-lg border bg-card p-3 shadow-xs transition-all hover:shadow-sm",
        isDragging && "opacity-50"
      )}
      draggable
      onClick={() => onTaskClick(task.id)}
      onDragEnd={handleDragEnd}
      onDragStart={handleDragStart}
      role="button"
      tabIndex={0}
    >
      <div className="flex items-start justify-between gap-2">
        <h4 className="wrap-break-word min-w-0 flex-1 font-medium text-sm leading-tight">
          {task.title}
        </h4>
        <DropdownMenu>
          <DropdownMenuTrigger asChild className="absolute top-1 right-1">
            <Button
              aria-label={`More options for ${task.title}`}
              className="opacity-0 transition-opacity group-hover:opacity-100"
              onClick={(e) => e.stopPropagation()}
              size="icon"
              type="button"
              variant="ghost"
            >
              <MoreVertical className="size-4" />
            </Button>
          </DropdownMenuTrigger>
          <DropdownMenuContent align="end">
            <DropdownMenuItem onClick={() => onStatusChange(task.id, "todo")}>
              <Circle className="size-4" />
              Move to To Do
            </DropdownMenuItem>
            <DropdownMenuItem
              onClick={() => onStatusChange(task.id, "in_progress")}
            >
              <Clock className="size-4" />
              Move to In Progress
            </DropdownMenuItem>
            <DropdownMenuItem onClick={() => onStatusChange(task.id, "done")}>
              <CheckCircle2 className="size-4" />
              Move to Done
            </DropdownMenuItem>
            <DropdownMenuSeparator />
            <DropdownMenuItem
              onClick={() => onDelete(task.id)}
              variant="destructive"
            >
              <X className="size-4" />
              Delete
            </DropdownMenuItem>
          </DropdownMenuContent>
        </DropdownMenu>
      </div>
      {task.description && (
        <p className="line-clamp-2 text-muted-foreground text-xs">
          {task.description}
        </p>
      )}
      <div className="flex flex-wrap items-center gap-2">
        <div
          className={cn("size-2 rounded-full", getPriorityColor(task.priority))}
          title={task.priority}
        />
        {task.assignees && task.assignees.length > 0 && (
          <div className="flex -space-x-1.5">
            {task.assignees.slice(0, 3).map((assignee) => (
              <Avatar
                className="size-5 border-2 border-background"
                key={assignee.id}
              >
                <AvatarImage alt={assignee.name} src={assignee.avatar} />
                <AvatarFallback className="text-xs">
                  {assignee.name.charAt(0).toUpperCase()}
                </AvatarFallback>
              </Avatar>
            ))}
            {task.assignees.length > 3 && (
              <div className="flex size-5 items-center justify-center rounded-full border-2 border-background bg-muted text-muted-foreground text-xs">
                +{task.assignees.length - 3}
              </div>
            )}
          </div>
        )}
        {task.dueDate && (
          <div
            className={cn(
              "flex items-center gap-1 text-xs",
              overdue ? "text-destructive" : "text-muted-foreground"
            )}
          >
            <Calendar className="size-3" />
            <span>{formatDate(task.dueDate)}</span>
          </div>
        )}
        {task.tags && task.tags.length > 0 && (
          <Badge className="text-xs" variant="outline">
            {task.tags[0]}
            {task.tags.length > 1 && ` +${task.tags.length - 1}`}
          </Badge>
        )}
      </div>
    </div>
  );
}

interface ColumnProps {
  column: { id: TaskStatus; title: string; limit?: number };
  tasks: Task[];
  taskCount: number;
  onTaskClick: (taskId: string) => void;
  onStatusChange: (taskId: string, status: TaskStatus) => void;
  onDelete: (taskId: string) => void;
  onCreateTask?: () => void;
  showColumnLimits?: boolean;
  isDragOver?: boolean;
  draggingTaskId?: string | null;
  onDragOver?: (columnId: TaskStatus) => void;
  onDragLeave?: () => void;
  onTaskDragStart?: (taskId: string) => void;
  onTaskDragEnd?: () => void;
}

function Column({
  column,
  tasks,
  taskCount,
  onTaskClick,
  onStatusChange,
  onDelete,
  onCreateTask,
  showColumnLimits,
  isDragOver = false,
  draggingTaskId,
  onDragOver,
  onDragLeave,
  onTaskDragStart,
  onTaskDragEnd,
}: ColumnProps) {
  const isAtLimit = column.limit !== undefined && taskCount >= column.limit;

  const handleDragOver = (e: React.DragEvent) => {
    e.preventDefault();
    e.stopPropagation();
    e.dataTransfer.dropEffect = "move";
    onDragOver?.(column.id);
  };

  const handleDrop = (e: React.DragEvent) => {
    e.preventDefault();
    e.stopPropagation();

    const taskId = e.dataTransfer.getData("text/plain");
    const fromStatus = e.dataTransfer.getData(
      "application/task-status"
    ) as TaskStatus;

    if (taskId && fromStatus !== column.id) {
      onStatusChange(taskId, column.id);
    }
    onDragLeave?.();
  };

  const handleDragEnter = (e: React.DragEvent) => {
    e.preventDefault();
    e.stopPropagation();
    onDragOver?.(column.id);
  };

  const handleDragLeave = (e: React.DragEvent) => {
    e.preventDefault();
    e.stopPropagation();
    // Only clear if we're actually leaving the column
    const rect = e.currentTarget.getBoundingClientRect();
    const x = e.clientX;
    const y = e.clientY;
    if (x < rect.left || x > rect.right || y < rect.top || y > rect.bottom) {
      onDragLeave?.();
    }
  };

  return (
    <div
      className={cn(
        "flex min-w-0 flex-1 flex-col gap-3 rounded-lg transition-colors",
        isDragOver && "bg-primary/5 ring-2 ring-primary ring-offset-2"
      )}
      onDragEnter={handleDragEnter}
      onDragLeave={handleDragLeave}
      onDragOver={handleDragOver}
      onDrop={handleDrop}
    >
      <div className="flex items-center justify-between">
        <div className="flex items-center gap-2">
          <h3 className="font-semibold text-sm">{column.title}</h3>
          <Badge className="text-xs" variant="secondary">
            {taskCount}
            {column.limit !== undefined && ` / ${column.limit}`}
          </Badge>
        </div>
        {onCreateTask && (
          <Button
            onClick={onCreateTask}
            size="icon"
            type="button"
            variant="ghost"
          >
            <Plus className="size-4" />
          </Button>
        )}
      </div>
      {showColumnLimits && isAtLimit && (
        <div className="rounded-md border border-yellow-500/50 bg-yellow-500/10 p-2">
          <p className="text-xs text-yellow-700 dark:text-yellow-400">
            Column limit reached
          </p>
        </div>
      )}
      <div className="flex flex-1 flex-col gap-2 overflow-y-auto">
        {tasks.length === 0 ? (
          <div className="flex flex-col items-center justify-center gap-2 rounded-lg border border-dashed p-8 text-center">
            <Circle className="size-6 text-muted-foreground" />
            <p className="text-muted-foreground text-xs">No tasks</p>
          </div>
        ) : (
          tasks.map((task) => (
            <TaskCard
              isDragging={draggingTaskId === task.id}
              key={task.id}
              onDelete={onDelete}
              onDragEnd={onTaskDragEnd}
              onDragStart={onTaskDragStart}
              onStatusChange={onStatusChange}
              onTaskClick={onTaskClick}
              task={task}
            />
          ))
        )}
      </div>
    </div>
  );
}

const defaultColumns = [
  { id: "todo" as TaskStatus, title: "To Do" },
  { id: "in_progress" as TaskStatus, title: "In Progress" },
  { id: "done" as TaskStatus, title: "Done" },
];

export default function TaskBoard({
  tasks = [],
  columns = defaultColumns,
  onTaskMove,
  onTaskSelect,
  onTaskCreate,
  onTaskUpdate,
  onTaskDelete,
  className,
  showColumnLimits = false,
}: TaskBoardProps) {
  const [draggingTaskId, setDraggingTaskId] = useState<string | null>(null);
  const [dragOverColumn, setDragOverColumn] = useState<TaskStatus | null>(null);

  const tasksByStatus = useMemo(() => {
    const grouped: Record<TaskStatus, Task[]> = {
      todo: [],
      in_progress: [],
      done: [],
      cancelled: [],
    };

    tasks.forEach((task) => {
      if (grouped[task.status]) {
        grouped[task.status].push(task);
      }
    });

    return grouped;
  }, [tasks]);

  const handleStatusChange = async (taskId: string, newStatus: TaskStatus) => {
    const task = tasks.find((t) => t.id === taskId);
    if (!task) return;

    await onTaskMove?.(taskId, task.status, newStatus);
    await onTaskUpdate?.(taskId, { status: newStatus });
  };

  const handleDelete = async (taskId: string) => {
    await onTaskDelete?.(taskId);
  };

  return (
    <Card className={cn("w-full shadow-xs", className)}>
      <CardHeader>
        <div className="flex flex-col gap-1">
          <CardTitle>Task Board</CardTitle>
          <CardDescription>
            {tasks.length} task{tasks.length !== 1 ? "s" : ""} across{" "}
            {columns.length} columns
          </CardDescription>
        </div>
      </CardHeader>
      <CardContent>
        <div className="flex gap-4 overflow-x-auto pb-2">
          {columns.map((column) => {
            const columnTasks = tasksByStatus[column.id] || [];
            return (
              <Column
                column={column}
                draggingTaskId={draggingTaskId}
                isDragOver={dragOverColumn === column.id}
                key={column.id}
                onCreateTask={
                  onTaskCreate ? () => onTaskCreate(column.id) : undefined
                }
                onDelete={handleDelete}
                onDragLeave={() => setDragOverColumn(null)}
                onDragOver={(columnId) => setDragOverColumn(columnId)}
                onStatusChange={handleStatusChange}
                onTaskClick={onTaskSelect || (() => {})}
                onTaskDragEnd={() => {
                  setDraggingTaskId(null);
                  setDragOverColumn(null);
                }}
                onTaskDragStart={(taskId) => setDraggingTaskId(taskId)}
                showColumnLimits={showColumnLimits}
                taskCount={columnTasks.length}
                tasks={columnTasks}
              />
            );
          })}
        </div>
      </CardContent>
    </Card>
  );
}

Similar components