Gantt charts visualize project timelines and task dependencies. Here is how to build one from scratch.
Data Types
// types/gantt.ts
export interface GanttTask {
id: string;
name: string;
start: Date;
end: Date;
progress: number; // 0-100
color?: string;
dependencies?: string[]; // IDs of dependent tasks
group?: string;
}
export interface GanttConfig {
startDate: Date;
endDate: Date;
dayWidth: number; // pixels per day
rowHeight: number;
}
Date Utilities
// lib/gantt-utils.ts
export function daysBetween(a: Date, b: Date): number {
const ms = b.getTime() - a.getTime();
return Math.ceil(ms / (1000 * 60 * 60 * 24));
}
export function addDays(date: Date, days: number): Date {
const result = new Date(date);
result.setDate(result.getDate() + days);
return result;
}
export function getDateRange(
tasks: { start: Date; end: Date }[]
): { start: Date; end: Date } {
const starts = tasks.map((t) => t.start.getTime());
const ends = tasks.map((t) => t.end.getTime());
const start = new Date(Math.min(...starts));
const end = new Date(Math.max(...ends));
// Add padding
start.setDate(start.getDate() - 3);
end.setDate(end.getDate() + 7);
return { start, end };
}
export function getMonths(start: Date, end: Date): { date: Date; days: number }[] {
const months: { date: Date; days: number }[] = [];
const current = new Date(start.getFullYear(), start.getMonth(), 1);
while (current <= end) {
const monthEnd = new Date(current.getFullYear(), current.getMonth() + 1, 0);
const visibleStart = current < start ? start : current;
const visibleEnd = monthEnd > end ? end : monthEnd;
months.push({
date: new Date(current),
days: daysBetween(visibleStart, visibleEnd) + 1,
});
current.setMonth(current.getMonth() + 1);
}
return months;
}
Gantt Chart Component
"use client";
import { useMemo, useState, useRef } from "react";
import type { GanttTask } from "@/types/gantt";
import { daysBetween, getDateRange, getMonths, addDays } from "@/lib/gantt-utils";
interface GanttChartProps {
tasks: GanttTask[];
onTaskUpdate?: (task: GanttTask) => void;
}
export function GanttChart({ tasks, onTaskUpdate }: GanttChartProps) {
const [dayWidth, setDayWidth] = useState(30);
const rowHeight = 40;
const headerHeight = 60;
const sidebarWidth = 200;
const containerRef = useRef<HTMLDivElement>(null);
const range = useMemo(() => getDateRange(tasks), [tasks]);
const totalDays = daysBetween(range.start, range.end);
const totalWidth = totalDays * dayWidth;
const months = useMemo(() => getMonths(range.start, range.end), [range]);
return (
<div className="border rounded-lg overflow-hidden">
{/* Toolbar */}
<div className="flex items-center gap-2 px-3 py-2 border-b bg-muted/30">
<span className="text-xs text-muted-foreground">Zoom:</span>
{[15, 30, 50].map((w) => (
<button
key={w}
onClick={() => setDayWidth(w)}
className={`px-2 py-0.5 text-xs rounded ${
dayWidth === w
? "bg-primary text-primary-foreground"
: "border hover:bg-muted"
}`}
>
{w === 15 ? "Month" : w === 30 ? "Week" : "Day"}
</button>
))}
</div>
<div ref={containerRef} className="flex overflow-auto max-h-[600px]">
{/* Sidebar */}
<div
className="shrink-0 border-r bg-card z-10 sticky left-0"
style={{ width: sidebarWidth }}
>
<div
className="border-b bg-muted/30 px-3 flex items-center text-xs font-medium"
style={{ height: headerHeight }}
>
Task
</div>
{tasks.map((task) => (
<div
key={task.id}
className="flex items-center px-3 border-b text-sm truncate"
style={{ height: rowHeight }}
>
{task.name}
</div>
))}
</div>
{/* Timeline */}
<div className="relative" style={{ minWidth: totalWidth }}>
{/* Month headers */}
<div className="sticky top-0 z-10 bg-card" style={{ height: headerHeight }}>
<div className="flex border-b" style={{ height: 30 }}>
{months.map((m, i) => (
<div
key={i}
className="border-r px-2 flex items-center text-xs font-medium"
style={{ width: m.days * dayWidth }}
>
{m.date.toLocaleString("en-US", {
month: "long",
year: "numeric",
})}
</div>
))}
</div>
{/* Day headers */}
<div className="flex border-b" style={{ height: 30 }}>
{Array.from({ length: totalDays }, (_, i) => {
const date = addDays(range.start, i);
const isWeekend = date.getDay() === 0 || date.getDay() === 6;
return (
<div
key={i}
className={`border-r flex items-center justify-center text-[10px] ${
isWeekend ? "bg-muted/50 text-muted-foreground" : ""
}`}
style={{ width: dayWidth }}
>
{dayWidth >= 30 ? date.getDate() : ""}
</div>
);
})}
</div>
</div>
{/* Grid lines */}
<div className="absolute" style={{ top: headerHeight }}>
{Array.from({ length: totalDays }, (_, i) => {
const date = addDays(range.start, i);
const isWeekend = date.getDay() === 0 || date.getDay() === 6;
return (
<div
key={i}
className={`absolute top-0 border-r ${
isWeekend ? "bg-muted/20" : ""
}`}
style={{
left: i * dayWidth,
width: dayWidth,
height: tasks.length * rowHeight,
}}
/>
);
})}
</div>
{/* Task bars */}
<div className="relative" style={{ top: 0 }}>
{tasks.map((task, index) => {
const offset = daysBetween(range.start, task.start);
const duration = daysBetween(task.start, task.end);
const left = offset * dayWidth;
const width = Math.max(duration * dayWidth, dayWidth);
const top = index * rowHeight + 8;
return (
<div
key={task.id}
className="absolute group"
style={{ left, top, width, height: rowHeight - 16 }}
>
{/* Background bar */}
<div
className="h-full rounded-sm opacity-30"
style={{ backgroundColor: task.color ?? "#3b82f6" }}
/>
{/* Progress fill */}
<div
className="absolute inset-y-0 left-0 rounded-sm"
style={{
width: `${task.progress}%`,
backgroundColor: task.color ?? "#3b82f6",
}}
/>
{/* Label */}
<div className="absolute inset-0 flex items-center px-2">
<span className="text-[11px] font-medium text-white truncate drop-shadow-sm">
{width > 60 ? task.name : ""}
</span>
</div>
{/* Tooltip on hover */}
<div className="absolute bottom-full left-0 mb-1 hidden group-hover:block z-20">
<div className="bg-popover border rounded shadow-lg p-2 text-xs whitespace-nowrap">
<div className="font-medium">{task.name}</div>
<div className="text-muted-foreground">
{task.start.toLocaleDateString()} - {task.end.toLocaleDateString()}
</div>
<div className="text-muted-foreground">{task.progress}% complete</div>
</div>
</div>
</div>
);
})}
</div>
{/* Today line */}
<TodayLine range={range} dayWidth={dayWidth} height={tasks.length * rowHeight + headerHeight} />
</div>
</div>
</div>
);
}
function TodayLine({
range,
dayWidth,
height,
}: {
range: { start: Date; end: Date };
dayWidth: number;
height: number;
}) {
const today = new Date();
today.setHours(0, 0, 0, 0);
if (today < range.start || today > range.end) return null;
const offset = daysBetween(range.start, today);
const left = offset * dayWidth + dayWidth / 2;
return (
<div
className="absolute top-0 w-px bg-red-500 z-10 pointer-events-none"
style={{ left, height }}
>
<div className="absolute -top-0 -translate-x-1/2 bg-red-500 text-white text-[9px] px-1 rounded-b">
Today
</div>
</div>
);
}
Usage
import { GanttChart } from "@/components/GanttChart";
import type { GanttTask } from "@/types/gantt";
const tasks: GanttTask[] = [
{
id: "1",
name: "Design Phase",
start: new Date(2026, 0, 6),
end: new Date(2026, 0, 17),
progress: 100,
color: "#8b5cf6",
},
{
id: "2",
name: "Frontend Development",
start: new Date(2026, 0, 13),
end: new Date(2026, 1, 7),
progress: 65,
color: "#3b82f6",
dependencies: ["1"],
},
{
id: "3",
name: "Backend Development",
start: new Date(2026, 0, 13),
end: new Date(2026, 1, 14),
progress: 40,
color: "#10b981",
dependencies: ["1"],
},
{
id: "4",
name: "Testing",
start: new Date(2026, 1, 10),
end: new Date(2026, 1, 21),
progress: 0,
color: "#f59e0b",
dependencies: ["2", "3"],
},
];
export default function ProjectPage() {
return <GanttChart tasks={tasks} />;
}
Need Project Management Tools?
We build custom project management dashboards and team collaboration tools. Contact us to learn more.