Task List
Task List is a React and Tailwind CSS data component from HextaUI, licensed MIT. Copy it and paste it into your project.
HextaUI
MIT
data
What it is
Task List is a React and Tailwind CSS data 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.
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 List
"use client";
import {
Calendar,
CheckCircle2,
Circle,
Clock,
Filter,
MoreVertical,
Plus,
Search,
SortAsc,
User,
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 { Checkbox } from "@/registry/new-york/ui/checkbox";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/registry/new-york/ui/dropdown-menu";
import {
InputGroup,
InputGroupAddon,
InputGroupInput,
} from "@/registry/new-york/ui/input-group";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/registry/new-york/ui/select";
import { Separator } from "@/registry/new-york/ui/separator";
export type TaskStatus = "todo" | "in_progress" | "done" | "cancelled";
export type TaskPriority = "low" | "medium" | "high" | "urgent";
export interface TaskAssignee {
id: string;
name: string;
avatar?: string;
}
export interface Task {
id: string;
title: string;
description?: string;
status: TaskStatus;
priority: TaskPriority;
assignees?: TaskAssignee[];
dueDate?: Date;
tags?: string[];
projectId?: string;
createdAt: Date;
updatedAt: Date;
}
export interface TaskListProps {
tasks?: Task[];
onTaskSelect?: (taskId: string) => void;
onTaskUpdate?: (taskId: string, updates: Partial<Task>) => Promise<void>;
onTaskDelete?: (taskId: string) => Promise<void>;
onBulkAction?: (taskIds: string[], action: string) => Promise<void>;
onCreateTask?: () => void;
className?: string;
showSearch?: boolean;
showFilters?: boolean;
showBulkActions?: boolean;
itemsPerPage?: number;
}
function formatDate(date: Date): string {
const now = new Date();
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const tomorrow = new Date(today);
tomorrow.setDate(tomorrow.getDate() + 1);
const dateOnly = new Date(
date.getFullYear(),
date.getMonth(),
date.getDate()
);
if (dateOnly.getTime() === today.getTime()) {
return "Today";
}
if (dateOnly.getTime() === tomorrow.getTime()) {
return "Tomorrow";
}
const month = date.toLocaleString("en-US", { month: "short" });
const day = date.getDate();
return `${month} ${day}`;
}
function getStatusIcon(status: TaskStatus) {
switch (status) {
case "done":
return CheckCircle2;
case "in_progress":
return Clock;
default:
return Circle;
}
}
function getStatusColor(status: TaskStatus): string {
switch (status) {
case "done":
return "text-green-600";
case "in_progress":
return "text-blue-600";
case "cancelled":
return "text-muted-foreground";
default:
return "text-muted-foreground";
}
}
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 getPriorityLabel(priority: TaskPriority): string {
return priority.charAt(0).toUpperCase() + priority.slice(1);
}
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 TaskItemProps {
task: Task;
isSelected: boolean;
onSelect: (taskId: string, selected: boolean) => void;
onTaskClick: (taskId: string) => void;
onStatusChange: (taskId: string, status: TaskStatus) => void;
onDelete: (taskId: string) => void;
}
function TaskItem({
task,
isSelected,
onSelect,
onTaskClick,
onStatusChange,
onDelete,
}: TaskItemProps) {
const overdue = isOverdue(task.dueDate);
return (
<div
className={cn(
"group flex items-start gap-3 rounded-lg border bg-card p-4 transition-colors hover:bg-accent",
isSelected && "border-primary bg-primary/5"
)}
>
<Checkbox
checked={isSelected}
onCheckedChange={(checked) => onSelect(task.id, checked === true)}
onClick={(e) => e.stopPropagation()}
/>
<div className="flex min-w-0 flex-1 items-start gap-3">
<button
className="flex min-w-0 flex-1 items-start gap-3 text-left"
onClick={() => onTaskClick(task.id)}
type="button"
>
{task.status === "done" ? (
<CheckCircle2
className={cn(
"mt-0.5 size-5 shrink-0",
getStatusColor(task.status)
)}
/>
) : task.status === "in_progress" ? (
<Clock
className={cn(
"mt-0.5 size-5 shrink-0",
getStatusColor(task.status)
)}
/>
) : (
<Circle
className={cn(
"mt-0.5 size-5 shrink-0",
getStatusColor(task.status)
)}
/>
)}
<div className="flex min-w-0 flex-1 flex-col gap-2">
<h4 className="wrap-break-word font-medium text-sm leading-tight">
{task.title}
</h4>
{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={getPriorityLabel(task.priority)}
/>
{task.assignees && task.assignees.length > 0 && (
<div className="flex items-center gap-1">
<User className="size-3 text-muted-foreground" />
<div className="flex -space-x-2">
{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>
</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 && (
<div className="flex flex-wrap gap-1">
{task.tags.slice(0, 3).map((tag) => (
<Badge className="text-xs" key={tag} variant="outline">
{tag}
</Badge>
))}
{task.tags.length > 3 && (
<Badge className="text-xs" variant="outline">
+{task.tags.length - 3}
</Badge>
)}
</div>
)}
</div>
</div>
</button>
<div className="flex shrink-0 items-center">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<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" />
Mark as To Do
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => onStatusChange(task.id, "in_progress")}
>
<Clock className="size-4" />
Mark as In Progress
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onStatusChange(task.id, "done")}>
<CheckCircle2 className="size-4" />
Mark as Done
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => onDelete(task.id)}
variant="destructive"
>
<X className="size-4" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</div>
);
}
export default function TaskList({
tasks = [],
onTaskSelect,
onTaskUpdate,
onTaskDelete,
onBulkAction,
onCreateTask,
className,
showSearch = true,
showFilters = true,
showBulkActions = true,
itemsPerPage = 20,
}: TaskListProps) {
const [searchQuery, setSearchQuery] = useState("");
const [statusFilter, setStatusFilter] = useState<string>("all");
const [priorityFilter, setPriorityFilter] = useState<string>("all");
const [sortBy, setSortBy] = useState<string>("updated");
const [selectedTasks, setSelectedTasks] = useState<Set<string>>(new Set());
const [currentPage, setCurrentPage] = useState(1);
const filteredTasks = useMemo(() => {
let filtered = [...tasks];
if (searchQuery.trim()) {
const query = searchQuery.toLowerCase();
filtered = filtered.filter(
(task) =>
task.title.toLowerCase().includes(query) ||
task.description?.toLowerCase().includes(query) ||
task.tags?.some((tag) => tag.toLowerCase().includes(query))
);
}
if (statusFilter !== "all") {
filtered = filtered.filter((task) => task.status === statusFilter);
}
if (priorityFilter !== "all") {
filtered = filtered.filter((task) => task.priority === priorityFilter);
}
filtered.sort((a, b) => {
switch (sortBy) {
case "title":
return a.title.localeCompare(b.title);
case "priority": {
const priorityOrder = { urgent: 4, high: 3, medium: 2, low: 1 };
return (
(priorityOrder[b.priority] || 0) - (priorityOrder[a.priority] || 0)
);
}
case "dueDate":
if (!(a.dueDate || b.dueDate)) return 0;
if (!a.dueDate) return 1;
if (!b.dueDate) return -1;
return a.dueDate.getTime() - b.dueDate.getTime();
case "updated":
default:
return b.updatedAt.getTime() - a.updatedAt.getTime();
}
});
return filtered;
}, [tasks, searchQuery, statusFilter, priorityFilter, sortBy]);
const paginatedTasks = useMemo(() => {
const start = (currentPage - 1) * itemsPerPage;
return filteredTasks.slice(start, start + itemsPerPage);
}, [filteredTasks, currentPage, itemsPerPage]);
const totalPages = Math.ceil(filteredTasks.length / itemsPerPage);
const handleTaskSelect = (taskId: string, selected: boolean) => {
setSelectedTasks((prev) => {
const next = new Set(prev);
if (selected) {
next.add(taskId);
} else {
next.delete(taskId);
}
return next;
});
};
const handleSelectAll = (checked: boolean) => {
if (checked) {
setSelectedTasks(new Set(paginatedTasks.map((t) => t.id)));
} else {
setSelectedTasks(new Set());
}
};
const handleStatusChange = async (taskId: string, status: TaskStatus) => {
await onTaskUpdate?.(taskId, { status });
};
const handleDelete = async (taskId: string) => {
await onTaskDelete?.(taskId);
setSelectedTasks((prev) => {
const next = new Set(prev);
next.delete(taskId);
return next;
});
};
const handleBulkAction = async (action: string) => {
if (selectedTasks.size === 0) return;
await onBulkAction?.(Array.from(selectedTasks), action);
setSelectedTasks(new Set());
};
const allSelected =
paginatedTasks.length > 0 &&
paginatedTasks.every((t) => selectedTasks.has(t.id));
const someSelected = paginatedTasks.some((t) => selectedTasks.has(t.id));
return (
<Card className={cn("w-full shadow-xs", className)}>
<CardHeader>
<div className="flex flex-col gap-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex min-w-0 flex-1 flex-col gap-2">
<CardTitle>Tasks</CardTitle>
<CardDescription>
{filteredTasks.length} task
{filteredTasks.length !== 1 ? "s" : ""}
</CardDescription>
</div>
{onCreateTask && (
<Button onClick={onCreateTask} type="button">
<Plus className="size-4" />
New Task
</Button>
)}
</div>
{showSearch && (
<InputGroup>
<InputGroupAddon>
<Search className="size-4" />
</InputGroupAddon>
<InputGroupInput
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Search tasks…"
type="search"
value={searchQuery}
/>
{searchQuery && (
<Button
aria-label="Clear search"
className="absolute top-1/2 right-2 -translate-y-1/2"
onClick={() => setSearchQuery("")}
size="icon"
type="button"
variant="ghost"
>
<X className="size-4" />
</Button>
)}
</InputGroup>
)}
{showFilters && (
<div className="flex flex-wrap items-center gap-2">
<Select onValueChange={setStatusFilter} value={statusFilter}>
<SelectTrigger className="w-full sm:w-[140px]">
<Filter className="size-4" />
<SelectValue placeholder="Status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Status</SelectItem>
<SelectItem value="todo">To Do</SelectItem>
<SelectItem value="in_progress">In Progress</SelectItem>
<SelectItem value="done">Done</SelectItem>
<SelectItem value="cancelled">Cancelled</SelectItem>
</SelectContent>
</Select>
<Select onValueChange={setPriorityFilter} value={priorityFilter}>
<SelectTrigger className="w-full sm:w-[140px]">
<SelectValue placeholder="Priority" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Priorities</SelectItem>
<SelectItem value="urgent">Urgent</SelectItem>
<SelectItem value="high">High</SelectItem>
<SelectItem value="medium">Medium</SelectItem>
<SelectItem value="low">Low</SelectItem>
</SelectContent>
</Select>
<Select onValueChange={setSortBy} value={sortBy}>
<SelectTrigger className="w-fit">
<SortAsc className="size-4" />
<SelectValue placeholder="Sort by" />
</SelectTrigger>
<SelectContent>
<SelectItem value="updated">Last Updated</SelectItem>
<SelectItem value="title">Title</SelectItem>
<SelectItem value="priority">Priority</SelectItem>
<SelectItem value="dueDate">Due Date</SelectItem>
</SelectContent>
</Select>
</div>
)}
{showBulkActions && selectedTasks.size > 0 && (
<div className="flex items-center gap-2 rounded-lg border bg-muted/30 p-2">
<span className="text-muted-foreground text-sm">
{selectedTasks.size} selected
</span>
<Separator orientation="vertical" />
<div className="flex gap-1">
<Button
onClick={() => handleBulkAction("complete")}
size="sm"
type="button"
variant="outline"
>
Complete
</Button>
<Button
onClick={() => handleBulkAction("delete")}
size="sm"
type="button"
variant="outline"
>
Delete
</Button>
</div>
</div>
)}
</div>
</CardHeader>
<CardContent>
{paginatedTasks.length === 0 ? (
<div className="flex flex-col items-center justify-center gap-4 py-12 text-center">
<div className="flex size-12 items-center justify-center rounded-full bg-muted">
<Circle className="size-6 text-muted-foreground" />
</div>
<div className="flex flex-col gap-2">
<p className="font-medium text-sm">
{searchQuery ||
statusFilter !== "all" ||
priorityFilter !== "all"
? "No tasks match your filters"
: "No tasks yet"}
</p>
<p className="text-muted-foreground text-sm">
{onCreateTask
? "Create your first task to get started"
: "Tasks will appear here"}
</p>
</div>
{onCreateTask && (
<Button onClick={onCreateTask} type="button" variant="outline">
<Plus className="size-4" />
Create Task
</Button>
)}
</div>
) : (
<div className="flex flex-col gap-2">
{showBulkActions && (
<div className="flex items-center gap-2 px-1">
<Checkbox
checked={allSelected}
onCheckedChange={handleSelectAll}
/>
<span className="text-muted-foreground text-xs">
Select all
</span>
</div>
)}
{paginatedTasks.map((task) => (
<TaskItem
isSelected={selectedTasks.has(task.id)}
key={task.id}
onDelete={handleDelete}
onSelect={handleTaskSelect}
onStatusChange={handleStatusChange}
onTaskClick={onTaskSelect || (() => {})}
task={task}
/>
))}
{totalPages > 1 && (
<div className="flex items-center justify-center gap-2 pt-4">
<Button
disabled={currentPage === 1}
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
size="sm"
type="button"
variant="outline"
>
Previous
</Button>
<span className="text-muted-foreground text-sm">
Page {currentPage} of {totalPages}
</span>
<Button
disabled={currentPage >= totalPages}
onClick={() =>
setCurrentPage((p) => Math.min(totalPages, p + 1))
}
size="sm"
type="button"
variant="outline"
>
Next
</Button>
</div>
)}
</div>
)}
</CardContent>
</Card>
);
}