A Gantt chart shows tasks as bars on a timeline, with dependencies between them. In React you have two sensible routes: install a library, or build a small chart yourself with CSS grid. This guide does both. The library part uses gantt-task-react, which is free, MIT-licensed, and gives you dependency arrows, drag and resize, progress handles and day/week/month views out of the box. The custom part is about forty lines and has no dependencies at all. Every screenshot below is the real chart running.
Everything here was built and rendered with React 19.3.0 and gantt-task-react 0.3.9 in a Vite project, and the screenshots are the actual output. Reference: gantt-task-react on npm.
Which React Gantt chart library should you use?
| Library | Licence | Good for |
|---|---|---|
| gantt-task-react | MIT, free | Dependencies, drag and resize, progress, day to year views. The default choice. |
| frappe-gantt (with a React wrapper) | MIT, free | A simpler bar chart, fewer options, very small. |
| DHTMLX Gantt | Commercial | Resource management, baselines, critical path, export. |
| Syncfusion / Bryntum Gantt | Commercial | Enterprise feature sets and paid support. |
| Your own CSS grid chart | Free | Read-only roadmaps, dashboards, anything where a library is overkill. |
If you only need to show a schedule, write it yourself and skip the dependency. If users will drag bars around and tasks depend on each other, take the library.
Install gantt-task-react
One package and one stylesheet. The published peer dependency still says React 18, so on React 19 npm refuses the install until you tell it to go ahead:
npm install gantt-task-react --legacy-peer-deps
import { Gantt, ViewMode } from "gantt-task-react";
import "gantt-task-react/dist/index.css";
We ran it on React 19.3.0 and everything on this page rendered correctly, arrows included, so the peer range is simply out of date rather than a real incompatibility. Check the repository before you ship it in production, and keep the flag in your install instructions so teammates hit the same result.
A React Gantt chart with dependencies
A task is a plain object: an id (a string), a type, a name, start and end as real Date objects, and progress from 0 to 100. Listing another task’s id in dependencies draws the arrow:
import { Gantt, ViewMode } from "gantt-task-react";
import "gantt-task-react/dist/index.css";
const tasks = [
{ id: "design", type: "task", name: "Design review", progress: 100,
start: new Date(2026, 2, 2), end: new Date(2026, 2, 10) },
{ id: "build", type: "task", name: "Build checkout", progress: 60, dependencies: ["design"],
start: new Date(2026, 2, 11), end: new Date(2026, 2, 25) },
{ id: "beta", type: "task", name: "Beta with 400 customers", progress: 15, dependencies: ["build"],
start: new Date(2026, 2, 26), end: new Date(2026, 3, 10) },
];
export default function App() {
return (
<div style={{ padding: 8, fontFamily: "system-ui" }}>
<Gantt
tasks={tasks}
viewMode={ViewMode.Week}
listCellWidth="200px" // "" hides the task list entirely
columnWidth={70}
/>
</div>
);
}

listCellWidth controls the table on the left: pass "" to hide it and show only the timeline. columnWidth sets how wide one column is, which is how you zoom the chart in and out.
Day, week and month views
ViewMode covers Hour through Year. Keep it in state, give each mode a sensible column width, and let the user switch. The screenshot was taken after a real click on the Month button:
import { useState } from "react";
import { Gantt, ViewMode } from "gantt-task-react";
import "gantt-task-react/dist/index.css";
const tasks = [
{ id: "design", type: "task", name: "Design review", progress: 100,
start: new Date(2026, 2, 2), end: new Date(2026, 2, 10) },
{ id: "build", type: "task", name: "Build checkout", progress: 60, dependencies: ["design"],
start: new Date(2026, 2, 11), end: new Date(2026, 2, 25) },
{ id: "beta", type: "task", name: "Beta with 400 customers", progress: 15, dependencies: ["build"],
start: new Date(2026, 2, 26), end: new Date(2026, 3, 10) },
];
const widths = { [ViewMode.Day]: 50, [ViewMode.Week]: 70, [ViewMode.Month]: 200 };
export default function App() {
const [view, setView] = useState(ViewMode.Week);
return (
<div style={{ padding: 8, fontFamily: "system-ui" }}>
<div style={{ marginBottom: 8 }}>
{[ViewMode.Day, ViewMode.Week, ViewMode.Month].map(mode => (
<button key={mode} id={`view-${mode}`} onClick={() => setView(mode)}
style={{ marginRight: 8, padding: "6px 12px", borderRadius: 6, cursor: "pointer",
border: "1px solid #0b6bcb", background: view === mode ? "#0b6bcb" : "#fff",
color: view === mode ? "#fff" : "#0b6bcb" }}>
{mode}
</button>
))}
</div>
<Gantt tasks={tasks} viewMode={view} columnWidth={widths[view]} listCellWidth="200px" />
</div>
);
}

Projects, milestones and colours
Setting type: "project" on a row and project: "<id>" on its children groups them, and a milestone renders as a diamond on a single date. Per-task styles are how you colour by status, and todayColor highlights today’s column:
import { Gantt, ViewMode } from "gantt-task-react";
import "gantt-task-react/dist/index.css";
const colors = {
done: { backgroundColor: "#0b6bcb", progressColor: "#084c91", backgroundSelectedColor: "#0b6bcb" },
risk: { backgroundColor: "#f0a202", progressColor: "#c08102", backgroundSelectedColor: "#f0a202" },
};
const tasks = [
{ id: "q1", type: "project", name: "Checkout revamp", progress: 55, hideChildren: false,
start: new Date(2026, 2, 2), end: new Date(2026, 3, 10) },
{ id: "design", type: "task", name: "Design review", project: "q1", progress: 100, styles: colors.done,
start: new Date(2026, 2, 2), end: new Date(2026, 2, 10) },
{ id: "build", type: "task", name: "Build checkout", project: "q1", progress: 60, styles: colors.risk,
dependencies: ["design"], start: new Date(2026, 2, 11), end: new Date(2026, 2, 25) },
{ id: "launch", type: "milestone", name: "Launch", project: "q1", progress: 0, styles: colors.done,
start: new Date(2026, 3, 10), end: new Date(2026, 3, 10) },
];
export default function App() {
return (
<div style={{ padding: 8, fontFamily: "system-ui" }}>
<Gantt
tasks={tasks}
viewMode={ViewMode.Week}
listCellWidth="200px"
columnWidth={70}
barCornerRadius={4}
todayColor="rgba(11, 107, 203, 0.08)"
/>
</div>
);
}

Drag, resize and progress: the event handlers
The chart is controlled: dragging a bar does not change anything by itself. It calls onDateChange with the updated task, and you put that into state (or send it to your API and then into state). onProgressChange works the same way for the progress handle, and returning false from either one cancels the change, which is how you enforce rules such as “cannot move a task into the past”:
import { useState } from "react";
import { Gantt, ViewMode } from "gantt-task-react";
import "gantt-task-react/dist/index.css";
const tasks = [
{ id: "design", type: "task", name: "Design review", progress: 100,
start: new Date(2026, 2, 2), end: new Date(2026, 2, 10) },
{ id: "build", type: "task", name: "Build checkout", progress: 60, dependencies: ["design"],
start: new Date(2026, 2, 11), end: new Date(2026, 2, 25) },
{ id: "beta", type: "task", name: "Beta with 400 customers", progress: 15, dependencies: ["build"],
start: new Date(2026, 2, 26), end: new Date(2026, 3, 10) },
];
export default function App() {
const [rows, setRows] = useState(tasks);
// dragging a bar fires onDateChange; resizing the right edge does too
const handleDateChange = (task) => {
console.log("moved:", task.name, task.start.toDateString(), "->", task.end.toDateString());
setRows(current => current.map(row => (row.id === task.id ? task : row)));
};
// dragging the progress handle fires onProgressChange
const handleProgressChange = (task) => {
console.log("progress:", task.name, task.progress + "%");
setRows(current => current.map(row => (row.id === task.id ? task : row)));
};
return (
<div style={{ padding: 8, fontFamily: "system-ui" }}>
<Gantt
tasks={rows}
viewMode={ViewMode.Week}
listCellWidth="200px"
columnWidth={70}
onDateChange={handleDateChange}
onProgressChange={handleProgressChange}
onClick={(task) => console.log("clicked:", task.name)}
onDoubleClick={(task) => console.log("open details for:", task.id)}
/>
</div>
);
}
Console output when a bar is clicked:
clicked: Design review
Keep the update immutable, replacing the changed task rather than editing it in place, in the same way as any other array in React state.
Loading tasks from an API
This is where most first attempts break: the library needs real Date objects, and an API sends ISO strings. Map the payload before it reaches the chart, convert ids to strings, and append T00:00:00 so the date is parsed in local time rather than UTC:
import { useEffect, useState } from "react";
import { Gantt, ViewMode } from "gantt-task-react";
import "gantt-task-react/dist/index.css";
// what an API usually sends: ISO strings, not Date objects
const payload = [
{ id: 41, title: "Design review", startsOn: "2026-03-02", dueOn: "2026-03-10", percent: 100 },
{ id: 42, title: "Build checkout", startsOn: "2026-03-11", dueOn: "2026-03-25", percent: 60, after: 41 },
];
const toTask = (row) => ({
id: String(row.id), // the id must be a string
type: "task",
name: row.title,
start: new Date(`${row.startsOn}T00:00:00`), // local midnight, not UTC
end: new Date(`${row.dueOn}T00:00:00`),
progress: row.percent,
...(row.after ? { dependencies: [String(row.after)] } : {}),
});
export default function App() {
const [tasks, setTasks] = useState([]);
useEffect(() => {
setTasks(payload.map(toTask)); // in a real app: fetch(...).then(...)
}, []);
console.log("mapped:", tasks.map(t => `${t.id} ${t.name} ${t.start.toDateString()}`));
if (tasks.length === 0) return <p style={{ fontFamily: "system-ui" }}>Loading the schedule...</p>;
return <Gantt tasks={tasks} viewMode={ViewMode.Week} listCellWidth="200px" columnWidth={70} />;
}
Console output (the component renders once before the data arrives, then again with it):
mapped: []
mapped: ['41 Design review Mon Mar 02 2026', '42 Build checkout Wed Mar 11 2026']
Render a placeholder while the array is empty, as above. Passing an empty task list to the chart throws, so the guard is not optional.
Build a Gantt chart without a library
If the chart is read-only, CSS grid does the job in a few lines: one column per day, and each bar spans from its start day to its end day. No dependency, no bundle cost, and it is easy to restyle. The loop is the same map over an array you would write for any list:
import "./gantt.css";
const start = new Date(2026, 2, 2); // the first day on the chart
const days = 30;
const tasks = [
{ id: 1, name: "Design review", from: new Date(2026, 2, 2), to: new Date(2026, 2, 10), progress: 100 },
{ id: 2, name: "Build checkout", from: new Date(2026, 2, 11), to: new Date(2026, 2, 25), progress: 60 },
{ id: 3, name: "Beta", from: new Date(2026, 2, 26), to: new Date(2026, 3, 1), progress: 15 },
];
const dayOf = (date) => Math.round((date - start) / 86_400_000);
export default function App() {
return (
<div className="gantt" style={{ gridTemplateColumns: `180px repeat(${days}, 1fr)` }}>
{tasks.map(task => (
<div className="row" key={task.id} style={{ display: "contents" }}>
<div className="label">{task.name}</div>
<div className="bar"
style={{ gridColumn: `${dayOf(task.from) + 2} / ${dayOf(task.to) + 3}` }}>
<span className="fill" style={{ width: `${task.progress}%` }} />
<span className="text">{task.progress}%</span>
</div>
</div>
))}
</div>
);
}