React gantt chart
A schedule on a time axis with groups, progress, a today line, dependency arrows with a critical path, milestones and lanes per person. On phones every task becomes a list entry with its own small track.
A project plan on one time axis
A gantt chart shows a schedule as bars on a time axis, so a team sees what runs when, what waits for what and how far each task has come. On phones every task becomes a list entry with its own small track instead of a squeezed chart.
Project plan groups tasks in four phases, each bar filled to its progress, with a summary per phase and a line for today. Dependencies draws arrows from each task to the ones that wait for it, the critical path keeps the accent and the pointer brings the arrows of one task forward. Roadmap with milestones lays four phases as pale bars over the year with milestones as diamonds and their dates, the next one called out. Lanes per person gives every person one row with several bars, colored by project, and a project in the legend brings its tasks forward.
Show the critical path, it is the part of the plan that decides the end date. Keep the scale to the length of the project, weeks for a quarter, months for a year. Put the today line in every view, a plan without now is hard to read.
Variants
Details
What Gantt chart brings along, read from the registry and the source.
- Category
- Charts
- Area
- Elements
- Variants
- 4
- Version
- 1.0.0 · since 1.2.0
- File
- components/ui/gantt-chart.tsx
- Brings along
- utilskitchartchart-figure
- npm packages
- None
- Languages
- English and German, 18 texts of its own
- Studio
- Adjust in code
- Build with AI
- Over MCP in Claude, Cursor, Copilot and Codex
Props
The GanttChartProps interface from components/ui/gantt-chart.tsx, with the comments from the code. Also takes every prop of Omit<ChartA11y, 'label'>, ChartFigureProps.
labeloptionalstringAccessible name of the chart. Default: the title.
variantoptionalGanttVariantplandependenciesmilestoneslanes
tasksrequiredGanttTask[]todayoptionalstringThe day of the today line, "2026-10-14". Leave out for none.
criticaloptionalstring[]dependencies: ids of the tasks on the critical path
groupsoptionalstring[]lanes: order of the projects (and so their colors). At most four.
Built-in texts
Gantt chart writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.
Show all 18 texts
| Key | English | German |
|---|---|---|
| task | English Task | German Aufgabe |
| start | English Start | German Beginn |
| end | English End | German Ende |
| duration | English Duration | German Dauer |
| progress | English Progress | German Fortschritt |
| days | English {n} days | German {n} Tage |
| day | English 1 day | German 1 Tag |
| range | English {a} to {b} | German {a} bis {b} |
| today | English Today | German Heute |
| after | English After {list} | German Nach {list} |
| milestone | English Milestone | German Meilenstein |
| next | English Next | German Als Nächstes |
| done | English Done | German Erledigt |
| group | English Group | German Gruppe |
| owner | English Owner | German Zuständig |
| critical | English Critical path | German Kritischer Pfad |
| summary | English {n} tasks from {a} to {b}. | German {n} Aufgaben von {a} bis {b}. |
| of | English {done} of {n} done | German {done} von {n} erledigt |
Accessibility
Read from the source of the component.
- Gantt chart handles the keys Escape, Arrow up, Arrow down, Arrow left, Arrow right, Home and End itself.
- Needs no client state. The server writes it in full, so it reads without JavaScript too.
Motion
- No animations, it stands still from the start.
Source
The file aiw add puts into your project. From then on it is yours.
Show the file, 521 lines
'use client';
import { useId, useState } from 'react';
import { cn, round, useTexts } from '@/lib/kit';
import { CHART_TEXTS, ChartRoot, ChartSurface, ChartTable, ChartTooltip, Swatch, fill, parseDate, seriesColor, textWidth, useEntrance, useIntlLocale, useShown, useWidth, type ChartA11y, type TooltipRow } from '@/components/ui/chart';
import { ChartFigure, HALO, PickLegend, type ChartFigureProps } from '@/components/ui/chart-figure';
/*
* A schedule on a time axis.
* plan tasks in groups, each bar filled to its progress, a summary
* bar per group and a line for today
* dependencies arrows from each task to the ones that wait for it, the
* critical path in the accent, the rest steps back
* milestones phases as pale bars, milestones as diamonds with their date,
* the next one called out
* lanes one row per person or team, several bars in a row, colored
* by project with a legend that picks a project
*
* On phones the chart turns into a list: each task with its dates, its
* progress and a small track that shows where it sits in the whole plan.
* Arrow keys step through the tasks from top to bottom on every size.
* Dates are "2026-09-14", the end day counts in full.
*/
export type GanttVariant = 'plan' | 'dependencies' | 'milestones' | 'lanes';
export interface GanttTask {
/** Unique id, used by `after` */
id: string;
label: string;
/** plan, dependencies, milestones: the group heading. lanes: the project, it picks the color. */
group?: string;
/** First day, "2026-09-14" */
start: string;
/** Last day, counted in full. Leave out for a milestone. */
end?: string;
/** Done share from 0 to 1 */
progress?: number;
/** A single date instead of a span */
milestone?: boolean;
/** Ids of the tasks this one waits for */
after?: string[];
/** lanes: the person or team that owns the task, one row each */
owner?: string;
}
export interface GanttChartProps extends Omit<ChartA11y, 'label'>, ChartFigureProps {
/** Accessible name of the chart. Default: the title. */
label?: string;
variant?: GanttVariant;
tasks: GanttTask[];
/** The day of the today line, "2026-10-14". Leave out for none. */
today?: string;
/** dependencies: ids of the tasks on the critical path */
critical?: string[];
/** lanes: order of the projects (and so their colors). At most four. */
groups?: string[];
className?: string;
}
const TEXTS = {
en: { task: 'Task', start: 'Start', end: 'End', duration: 'Duration', progress: 'Progress', days: '{n} days', day: '1 day', range: '{a} to {b}', today: 'Today', after: 'After {list}', milestone: 'Milestone', next: 'Next', done: 'Done', group: 'Group', owner: 'Owner', critical: 'Critical path', summary: '{n} tasks from {a} to {b}.', of: '{done} of {n} done' },
de: { task: 'Aufgabe', start: 'Beginn', end: 'Ende', duration: 'Dauer', progress: 'Fortschritt', days: '{n} Tage', day: '1 Tag', range: '{a} bis {b}', today: 'Heute', after: 'Nach {list}', milestone: 'Meilenstein', next: 'Als Nächstes', done: 'Erledigt', group: 'Gruppe', owner: 'Zuständig', critical: 'Kritischer Pfad', summary: '{n} Aufgaben von {a} bis {b}.', of: '{done} von {n} erledigt' },
};
const DAY = 864e5;
type Row = { kind: 'group'; name: string; tasks: number[] } | { kind: 'task'; idx: number } | { kind: 'lane'; name: string; tasks: number[] };
/**
* A Gantt chart: plan with progress, dependencies, milestones or lanes per person.
* <GanttChart variant="plan" today="2026-10-14" tasks={[{ id: 'brief', label: 'Brief', group: 'Start', start: '2026-09-01', end: '2026-09-05', progress: 1 }]} />
*/
export function GanttChart(props: GanttChartProps) {
const { variant = 'plan', tasks, today, critical = [], label, summary, labels, animate = true, title, value, delta, meta, source, framed, className } = props;
const t = useTexts(TEXTS, undefined);
const ct = useTexts(CHART_TEXTS, labels);
const intl = useIntlLocale();
const uid = useId().replace(/[^a-zA-Z0-9]/g, '');
const [wrapRef, width] = useWidth<HTMLDivElement>(760);
const [rootRef, anim] = useEntrance<HTMLDivElement>(animate);
const [active, setActive] = useState<number | null>(null);
const { shown, open, jump } = useShown(active);
const [focusGroup, setFocusGroup] = useState<number | null>(null);
const phone = width < 560;
const name = label ?? title ?? '';
const pct = new Intl.NumberFormat(intl, { style: 'percent', maximumFractionDigits: 0 });
const dShort = new Intl.DateTimeFormat(intl, { timeZone: 'UTC', day: 'numeric', month: 'short' });
const dLong = new Intl.DateTimeFormat(intl, { timeZone: 'UTC', weekday: 'short', day: 'numeric', month: 'long' });
const monthF = new Intl.DateTimeFormat(intl, { timeZone: 'UTC', month: 'short' });
const delay = (ms: number) => (anim ? ({ '--kitch-d': `${round(ms, 0)}ms` } as React.CSSProperties) : undefined);
// Dates
const t0 = (s: string) => parseDate(s)?.getTime() ?? 0;
const startOf = (k: GanttTask) => t0(k.start);
const endOf = (k: GanttTask) => (k.milestone || !k.end ? startOf(k) : t0(k.end) + DAY);
const days = (k: GanttTask) => Math.max(1, Math.round((endOf(k) - startOf(k)) / DAY));
const durText = (k: GanttTask) => (days(k) === 1 ? t.day : fill(t.days, { n: new Intl.NumberFormat(intl).format(days(k)) }));
const rangeText = (k: GanttTask) => (k.milestone || !k.end ? dShort.format(new Date(startOf(k))) : fill(t.range, { a: dShort.format(new Date(startOf(k))), b: dShort.format(new Date(endOf(k) - DAY)) }));
const todayT = today ? t0(today) : null;
// Groups and rows
const groupNames = variant === 'lanes' ? (props.groups ?? [...new Set(tasks.map((k) => k.group ?? ''))].filter(Boolean)).slice(0, 4) : [...new Set(tasks.map((k) => k.group ?? ''))];
const rows: Row[] = [];
if (variant === 'lanes') {
const owners = [...new Set(tasks.map((k) => k.owner ?? ''))];
owners.forEach((o) => rows.push({ kind: 'lane', name: o, tasks: tasks.map((k, i) => (k.owner ?? '') === o ? i : -1).filter((i) => i >= 0) }));
} else {
groupNames.forEach((g) => {
const members = tasks.map((k, i) => ((k.group ?? '') === g ? i : -1)).filter((i) => i >= 0);
if (g) rows.push({ kind: 'group', name: g, tasks: members });
members.forEach((i) => rows.push({ kind: 'task', idx: i }));
});
}
// Keyboard order: tasks as they appear from top to bottom (lanes: by row, then by start)
const order = rows.flatMap((r) => (r.kind === 'task' ? [r.idx] : r.kind === 'lane' ? [...r.tasks].sort((a, b) => startOf(tasks[a]) - startOf(tasks[b])) : []));
const byId = new Map(tasks.map((k, i) => [k.id, i]));
// Time domain, padded to whole weeks or months
const lo = Math.min(...tasks.map(startOf), todayT ?? Infinity);
const hi = Math.max(...tasks.map(endOf), todayT !== null ? todayT + DAY : -Infinity);
const spanDays = Math.max(1, (hi - lo) / DAY);
const weekly = spanDays <= 84;
const d0 = new Date(lo);
let domainLo: number;
let domainHi: number;
const ticks: { at: number; text: string }[] = [];
if (weekly) {
domainLo = lo - ((d0.getUTCDay() + 6) % 7) * DAY;
domainHi = domainLo + Math.ceil((hi - domainLo) / (7 * DAY)) * 7 * DAY;
for (let x = domainLo; x <= domainHi; x += 7 * DAY) ticks.push({ at: x, text: dShort.format(new Date(x)) });
} else {
domainLo = Date.UTC(d0.getUTCFullYear(), d0.getUTCMonth(), 1);
const h = new Date(hi - 1);
domainHi = Date.UTC(h.getUTCFullYear(), h.getUTCMonth() + 1, 1);
for (let y = d0.getUTCFullYear(), m = d0.getUTCMonth(); Date.UTC(y, m, 1) <= domainHi; m++) ticks.push({ at: Date.UTC(y, m, 1), text: monthF.format(new Date(Date.UTC(y, m, 1))) });
}
// Colors and emphasis
const groupIndex = (k: GanttTask) => Math.max(0, groupNames.indexOf(k.group ?? ''));
const isCritical = (k: GanttTask) => variant === 'dependencies' && critical.includes(k.id);
const faded = (k: GanttTask) => (variant === 'lanes' && focusGroup !== null && groupIndex(k) !== focusGroup) || (variant === 'dependencies' && critical.length > 0 && !isCritical(k));
const barColor = (k: GanttTask) => (variant === 'lanes' ? seriesColor(groupIndex(k)) : variant === 'milestones' ? 'color-mix(in oklab, var(--primary) 30%, var(--card))' : variant === 'dependencies' && critical.length && !isCritical(k) ? 'var(--kitch-2)' : 'var(--kitch-1)');
const trackColor = (k: GanttTask) => (variant === 'dependencies' && critical.length && !isCritical(k) ? 'color-mix(in oklab, var(--muted-foreground) 26%, var(--card))' : 'color-mix(in oklab, var(--primary) 24%, var(--card))');
const nextMilestone = variant === 'milestones' && todayT !== null ? tasks.map((k, i) => ({ k, i })).filter(({ k }) => k.milestone && startOf(k) >= todayT).sort((a, b) => startOf(a.k) - startOf(b.k))[0]?.i : undefined;
// Texts
const tipRows = (i: number): TooltipRow[] => {
const k = tasks[i];
const out: TooltipRow[] = [];
if (k.milestone) out.push({ key: 'm', label: t.milestone, value: dShort.format(new Date(startOf(k))) });
else {
out.push({ key: 's', label: t.start, value: dShort.format(new Date(startOf(k))) });
out.push({ key: 'e', label: t.end, value: dShort.format(new Date(endOf(k) - DAY)) });
out.push({ key: 'd', label: t.duration, value: durText(k) });
}
if (k.progress !== undefined && !k.milestone) out.push({ key: 'p', label: t.progress, value: pct.format(k.progress) });
if (variant === 'lanes' && k.owner) out.push({ key: 'o', label: t.owner, value: k.owner });
return out;
};
const afterText = (k: GanttTask) => (k.after?.length ? fill(t.after, { list: k.after.map((id) => tasks[byId.get(id) ?? -1]?.label).filter(Boolean).join(', ') }) : '');
const live = active !== null && tasks[active] ? `${tasks[active].label}. ${rangeText(tasks[active])}${tasks[active].milestone ? '' : `, ${durText(tasks[active])}`}${tasks[active].progress !== undefined && !tasks[active].milestone ? `, ${t.progress} ${pct.format(tasks[active].progress!)}` : ''}${afterText(tasks[active]) ? `. ${afterText(tasks[active])}` : ''}` : '';
const doneCount = tasks.filter((k) => (k.progress ?? 0) >= 1).length;
const auto = `${fill(t.summary, { n: String(tasks.length), a: dShort.format(new Date(lo)), b: dShort.format(new Date(hi - DAY)) })} ${fill(t.of, { done: String(doneCount), n: String(tasks.length) })}.`;
const table = (
<ChartTable
caption={name}
head={[t.task, variant === 'lanes' ? t.owner : t.group, t.start, t.end, t.duration, t.progress]}
rows={order.map((i) => {
const k = tasks[i];
return [k.label, (variant === 'lanes' ? k.owner : k.group) ?? '', dShort.format(new Date(startOf(k))), k.milestone ? t.milestone : dShort.format(new Date(endOf(k) - DAY)), k.milestone ? '' : durText(k), k.progress === undefined ? ct.noData : pct.format(k.progress)];
})}
/>
);
const onKeyDown = (e: React.KeyboardEvent) => {
const cur = active === null ? -1 : order.indexOf(active);
let next = cur;
if (e.key === 'ArrowDown' || e.key === 'ArrowRight') next = Math.min(order.length - 1, cur + 1);
else if (e.key === 'ArrowUp' || e.key === 'ArrowLeft') next = Math.max(0, cur - 1);
else if (e.key === 'Home') next = 0;
else if (e.key === 'End') next = order.length - 1;
else if (e.key === 'Escape') return setActive(null);
else return;
e.preventDefault();
setActive(order[next]);
};
const surface = {
label: name,
summary: summary ?? auto,
labels,
live,
table,
onKeyDown,
onFocus: (e: React.FocusEvent) => e.target === e.currentTarget && active === null && (e.currentTarget as HTMLElement).matches(':focus-visible') && setActive(order[0] ?? null),
onBlur: () => setActive(null),
onPointerLeave: (e: React.PointerEvent) => e.pointerType === 'mouse' && setActive(null),
};
const legend = variant === 'lanes' && groupNames.length > 1 ? <PickLegend className="mb-4" active={focusGroup} onActive={setFocusGroup} items={groupNames.map((g, i) => ({ label: g, key: <Swatch color={seriesColor(i)} /> }))} /> : null;
const criticalKey =
variant === 'dependencies' && critical.length > 0 ? (
<p className="mb-4 flex flex-wrap items-center gap-x-4 gap-y-1 text-[12px] text-muted-foreground">
<span className="inline-flex items-center gap-1.5">
<Swatch color="var(--kitch-1)" />
{t.critical}
</span>
<span className="inline-flex items-center gap-1.5">
<Swatch color="var(--kitch-2)" />
{t.task}
</span>
</p>
) : null;
/* ---------------- phone: a list with a small track per task ---------------- */
if (phone) {
const frac = (x: number) => Math.max(0, Math.min(1, (x - lo) / (hi - lo || 1)));
const todayFrac = todayT !== null ? frac(todayT) : null;
const sections = rows.filter((r): r is Extract<Row, { tasks: number[] }> => r.kind !== 'task').concat(groupNames.includes('') && variant !== 'lanes' ? [{ kind: 'group', name: '', tasks: tasks.map((k, i) => (k.group ? -1 : i)).filter((i) => i >= 0) }] : []);
return (
<ChartFigure title={title} value={value} delta={delta} meta={meta} source={source} framed={framed} className={className}>
<ChartRoot anim={anim} rootRef={rootRef}>
{legend}
{criticalKey}
<div ref={wrapRef}>
<ChartSurface {...surface}>
{/* One list per section, the headings stay in reading order */}
<div className="grid">
{sections.map((r, ri) => {
const members = r.kind === 'group' ? r.tasks : [...r.tasks].sort((a, b) => startOf(tasks[a]) - startOf(tasks[b]));
const done = members.filter((i) => (tasks[i].progress ?? 0) >= 1).length;
return (
<section key={ri} className={cn(ri > 0 && 'mt-5')}>
{r.name && <h4 className="flex items-baseline justify-between gap-3 pb-2 text-[12px] font-medium uppercase tracking-[0.06em] text-muted-foreground">
<span className="min-w-0 truncate">{r.name}</span>
{r.kind === 'group' && <span className="shrink-0 normal-case tracking-normal tabular-nums">{fill(t.of, { done: String(done), n: String(members.length) })}</span>}
</h4>}
<ul className="grid gap-px overflow-hidden rounded-lg border border-border bg-border">
{members.map((i, j) => {
const k = tasks[i];
const a = frac(startOf(k));
const b = frac(endOf(k));
const on = active === i;
return (
<li key={k.id} onPointerDown={() => setActive(i)} className={cn('bg-card px-3.5 py-3 transition-colors', on && 'bg-[color-mix(in_oklab,var(--primary)_7%,var(--card))]', faded(k) && 'text-muted-foreground')}>
<div className="flex items-baseline justify-between gap-3">
<span className={cn('flex min-w-0 items-center gap-2 text-[14px] font-medium', faded(k) ? 'text-muted-foreground' : 'text-foreground')}>
{k.milestone && (
<svg width="10" height="10" viewBox="0 0 10 10" aria-hidden className="shrink-0">
<path d="M5 0.5L9.5 5L5 9.5L0.5 5Z" fill={i === nextMilestone ? 'var(--primary)' : 'var(--foreground)'} />
</svg>
)}
{variant === 'lanes' && <Swatch color={seriesColor(groupIndex(k))} />}
<span className="truncate">{k.label}</span>
</span>
{k.progress !== undefined && !k.milestone && <span className={cn('shrink-0 text-[12px] font-medium tabular-nums', k.progress >= 1 ? 'text-success-foreground' : 'text-muted-foreground')}>{k.progress >= 1 ? t.done : pct.format(k.progress)}</span>}
{k.milestone && i === nextMilestone && <span className="shrink-0 text-[12px] font-medium text-primary">{t.next}</span>}
</div>
<p className="mt-0.5 text-[12px] tabular-nums text-muted-foreground">
{rangeText(k)}
{!k.milestone && ` · ${durText(k)}`}
{variant === 'lanes' && k.group ? ` · ${k.group}` : ''}
</p>
{afterText(k) && <p className="mt-0.5 truncate text-[12px] text-muted-foreground">{afterText(k)}</p>}
<span aria-hidden className="relative mt-2.5 block h-1.5 rounded-full bg-track">
{todayFrac !== null && <span className="absolute -inset-y-1 w-px bg-[var(--kitch-axis)]" style={{ left: `${round(todayFrac * 100, 2)}%` }} />}
{k.milestone ? (
<span className="absolute top-1/2 size-2.5 -translate-x-1/2 -translate-y-1/2 rotate-45 rounded-[1px]" style={{ left: `clamp(5px, ${round(a * 100, 2)}%, calc(100% - 5px))`, background: i === nextMilestone ? 'var(--primary)' : 'var(--foreground)' }} />
) : (
<span className={cn('absolute inset-y-0 overflow-hidden rounded-full', anim && 'kitch-grow-x')} style={{ left: `${round(a * 100, 2)}%`, width: `${round(Math.max(0.012, b - a) * 100, 2)}%`, background: trackColor(k), ...delay(120 + j * 60) }}>
<span className="absolute inset-y-0 left-0 rounded-full" style={{ width: `${round((variant === 'milestones' || variant === 'lanes' ? 1 : (k.progress ?? 1)) * 100, 2)}%`, background: barColor(k) }} />
</span>
)}
</span>
</li>
);
})}
</ul>
</section>
);
})}
</div>
{todayT !== null && (
<p className="mt-3 flex items-center gap-2 text-[12px] text-muted-foreground">
<span aria-hidden className="h-3 w-px bg-[var(--kitch-axis)]" />
{`${t.today}, ${dShort.format(new Date(todayT))}`}
</p>
)}
</ChartSurface>
</div>
</ChartRoot>
</ChartFigure>
);
}
/* ---------------- tablet and desktop: the time axis ---------------- */
const labelW = Math.min(240, Math.max(...rows.map((r) => (r.kind === 'task' ? textWidth(tasks[r.idx].label, 13) + 16 : textWidth(r.name, 12) + 8)), 80) + 20);
const left = labelW;
// Room on the right for the share after the last bar and for a milestone label
const right = width - (variant === 'plan' || variant === 'dependencies' ? 52 : 12);
const top = 34;
const rowH = variant === 'lanes' ? 44 : 34;
const groupH = 34;
const ys: number[] = [];
let acc = top;
rows.forEach((r) => {
ys.push(acc);
acc += r.kind === 'group' ? groupH : rowH;
});
const height = acc + 8;
const X = (x: number) => round(left + ((x - domainLo) / (domainHi - domainLo || 1)) * (right - left));
const barH = variant === 'lanes' ? 24 : variant === 'milestones' ? 12 : 16;
const rowOf = (i: number) => rows.findIndex((r) => (r.kind === 'task' && r.idx === i) || (r.kind === 'lane' && r.tasks.includes(i)));
const midY = (i: number) => {
const r = rowOf(i);
return round(ys[r] + rowH / 2);
};
const tickEvery = Math.max(1, Math.ceil((Math.max(...ticks.map((x) => textWidth(x.text, 11))) + 14) / Math.max(1, (right - left) / Math.max(1, ticks.length - 1))));
const pick = (e: React.PointerEvent<HTMLDivElement>) => {
const b = e.currentTarget.getBoundingClientRect();
const px = (e.clientX - b.left) * (width / (b.width || width));
const py = e.clientY - b.top;
const ri = ys.findIndex((y, j) => py >= y && py < y + (rows[j].kind === 'group' ? groupH : rowH));
const r = rows[ri];
if (!r) return setActive(null);
if (r.kind === 'task') return setActive(r.idx);
if (r.kind === 'lane') {
// The bar under the pointer, or the nearest one in the row
const near = [...r.tasks].sort((a, b2) => {
const da = px < X(startOf(tasks[a])) ? X(startOf(tasks[a])) - px : px > X(endOf(tasks[a])) ? px - X(endOf(tasks[a])) : 0;
const db = px < X(startOf(tasks[b2])) ? X(startOf(tasks[b2])) - px : px > X(endOf(tasks[b2])) ? px - X(endOf(tasks[b2])) : 0;
return da - db;
})[0];
return setActive(near ?? null);
}
setActive(null);
};
// Dependency arrows
const arrows: { d: string; crit: boolean; from: number; to: number }[] = [];
if (variant === 'dependencies' || variant === 'plan') {
tasks.forEach((k, j) => {
(k.after ?? []).forEach((id) => {
const i = byId.get(id);
if (i === undefined || rowOf(i) < 0 || rowOf(j) < 0) return;
if (variant === 'plan') return;
const x1 = X(endOf(tasks[i]));
const y1 = midY(i);
const x2 = X(startOf(k)) - (k.milestone ? 7 : 1);
const y2 = midY(j);
const down = y2 > y1 ? 1 : -1;
let d: string;
if (x2 - x1 >= 14) d = `M${x1} ${y1}H${round(x1 + 7)}V${y2}H${x2}`;
else {
const ym = round(y1 + down * (rowH / 2));
d = `M${x1} ${y1}H${round(x1 + 7)}V${ym}H${round(x2 - 8)}V${y2}H${x2}`;
}
arrows.push({ d, crit: isCritical(tasks[i]) && isCritical(k), from: i, to: j });
});
});
}
const tipX = shown !== null && tasks[shown] ? X(endOf(tasks[shown])) + (tasks[shown].milestone ? 8 : 0) : 0;
const tipY = shown !== null && tasks[shown] ? Math.max(0, midY(shown) - 18) : 0;
return (
<ChartFigure title={title} value={value} delta={delta} meta={meta} source={source} framed={framed} className={className}>
<ChartRoot anim={anim} rootRef={rootRef}>
{legend}
{criticalKey}
<div ref={wrapRef}>
<ChartSurface {...surface} onPointerMove={pick} onPointerDown={pick}>
<svg width="100%" height={height} viewBox={`0 0 ${width} ${height}`} aria-hidden className="block overflow-visible">
<defs>
<marker id={`${uid}a`} viewBox="0 0 8 8" refX="7" refY="4" markerWidth="7" markerHeight="7" orient="auto">
<path d="M1 1L7 4L1 7" fill="none" stroke="var(--muted-foreground)" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</marker>
<marker id={`${uid}b`} viewBox="0 0 8 8" refX="7" refY="4" markerWidth="7" markerHeight="7" orient="auto">
<path d="M1 1L7 4L1 7" fill="none" stroke="var(--primary)" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</marker>
</defs>
{/* Row bands and group headings */}
{rows.map((r, ri) =>
r.kind === 'group' ? (
<g key={ri}>
{ri > 0 && <line x1={0} x2={right} y1={ys[ri] + 0.5} y2={ys[ri] + 0.5} stroke="var(--kitch-grid)" strokeWidth="1" shapeRendering="crispEdges" />}
<text x={0} y={ys[ri] + groupH / 2 + 1} dy="0.34em" className="text-[11px] font-semibold uppercase tracking-[0.06em]" fill="var(--muted-foreground)">
{r.name}
</text>
</g>
) : r.kind === 'lane' ? (
<g key={ri}>
{ri > 0 && <line x1={0} x2={right} y1={ys[ri] + 0.5} y2={ys[ri] + 0.5} stroke="var(--kitch-grid)" strokeWidth="1" shapeRendering="crispEdges" />}
<text x={0} y={ys[ri] + rowH / 2} dy="0.34em" className={cn('text-[13px] font-medium', shown !== null && rowOf(shown) === ri && open ? 'fill-foreground' : '')} fill="var(--foreground)">
{r.name}
</text>
</g>
) : null,
)}
{/* Time grid */}
<g className="text-[11px] tabular-nums" fill="var(--muted-foreground)">
{ticks.map((x, i) => (
<g key={x.at}>
<line x1={X(x.at)} x2={X(x.at)} y1={top - 6} y2={height - 8} stroke="var(--kitch-grid)" strokeWidth="1" shapeRendering="crispEdges" />
{i % tickEvery === 0 && i < ticks.length - 1 && !(todayT !== null && Math.abs(X(x.at) - X(todayT)) < textWidth(t.today, 11) / 2 + textWidth(x.text, 11) / (weekly ? 2 : 1) + 12) && (
<text x={X(x.at) + (weekly ? 0 : 6)} y={14} textAnchor={weekly ? 'middle' : 'start'}>
{x.text}
</text>
)}
</g>
))}
</g>
{/* Active row */}
{shown !== null && rowOf(shown) >= 0 && <rect x={0} y={0} width={right} height={rowH} rx="6" fill="var(--foreground)" className="kitch-glide" data-jump={jump ? '' : undefined} style={{ transform: `translateY(${ys[rowOf(shown)]}px)`, opacity: open ? 0.045 : 0 }} />}
{/* Task labels */}
{rows.map((r, ri) =>
r.kind === 'task' ? (
<text key={`l${ri}`} x={rows.some((q) => q.kind === 'group') ? 12 : 0} y={ys[ri] + rowH / 2} dy="0.34em" className={cn('text-[13px] transition-[fill,opacity] duration-200', active === r.idx && 'font-medium')} fill={faded(tasks[r.idx]) ? 'var(--muted-foreground)' : 'var(--foreground)'}>
{tasks[r.idx].label}
</text>
) : null,
)}
{/* Group summary bars */}
{variant === 'plan' &&
rows.map((r, ri) => {
if (r.kind !== 'group' || !r.tasks.length) return null;
const a = Math.min(...r.tasks.map((i) => startOf(tasks[i])));
const b = Math.max(...r.tasks.map((i) => endOf(tasks[i])));
const total = r.tasks.reduce((s, i) => s + days(tasks[i]), 0);
const done = r.tasks.reduce((s, i) => s + days(tasks[i]) * (tasks[i].progress ?? 0), 0);
const y = ys[ri] + groupH / 2;
return (
<g key={`s${ri}`} className={anim ? 'kitch-fade' : undefined} style={delay(80 + ri * 30)}>
<rect x={X(a)} y={y - 2} width={Math.max(2, X(b) - X(a))} height={4} rx={2} fill="var(--track)" style={{ fill: 'color-mix(in oklab, var(--foreground) 12%, transparent)' }} />
<rect x={X(a)} y={y - 2} width={round(Math.max(0, (X(b) - X(a)) * (done / (total || 1))))} height={4} rx={2} fill="var(--foreground)" opacity={0.55} />
<text x={X(b) + 8} y={y} dy="0.34em" className="text-[11px] tabular-nums" fill="var(--muted-foreground)" style={HALO}>
{pct.format(done / (total || 1))}
</text>
</g>
);
})}
{/* Arrows */}
{arrows.map((a, k) => (
<path
key={k}
d={a.d}
fill="none"
stroke={a.crit ? 'var(--primary)' : 'var(--muted-foreground)'}
strokeWidth={a.crit ? 1.5 : 1.25}
strokeLinejoin="round"
markerEnd={`url(#${uid}${a.crit ? 'b' : 'a'})`}
opacity={shown !== null && open && a.from !== shown && a.to !== shown ? 0.25 : a.crit ? 1 : 0.7}
className={cn('transition-opacity duration-200', anim && 'kitch-fade')}
style={delay(700 + k * 30)}
/>
))}
{/* Bars and milestones */}
{order.map((i, n) => {
const k = tasks[i];
const y = midY(i);
const x1 = X(startOf(k));
const x2 = X(endOf(k));
const w = Math.max(3, x2 - x1);
const dim = faded(k) && variant === 'lanes' ? 0.25 : 1;
if (k.milestone) {
const s = variant === 'milestones' ? 8 : 7;
const next = i === nextMilestone;
// The name stands in the row label already, the diamond carries its date
const text = dShort.format(new Date(startOf(k)));
const leftSide = x1 + 12 + textWidth(text, 12) > right;
return (
<g key={k.id} className={anim ? 'kitch-pop' : undefined} style={delay(300 + n * 50)} opacity={dim}>
{next && <circle cx={x1} cy={y} r={s + 6} fill="color-mix(in oklab, var(--primary) 16%, transparent)" />}
<path d={`M${x1} ${round(y - s)}L${round(x1 + s)} ${y}L${x1} ${round(y + s)}L${round(x1 - s)} ${y}Z`} fill={next || variant !== 'milestones' ? 'var(--primary)' : 'var(--foreground)'} stroke="var(--kitch-surface)" strokeWidth="2" strokeLinejoin="round" />
{variant === 'milestones' && (
<text x={leftSide ? x1 - 14 : x1 + 14} y={y} dy="0.34em" textAnchor={leftSide ? 'end' : 'start'} className={cn('text-[12px]', next ? 'font-semibold' : 'font-medium')} fill="var(--foreground)" style={HALO}>
{next ? `${t.next}, ${text}` : text}
</text>
)}
</g>
);
}
const prog = Math.max(0, Math.min(1, k.progress ?? (variant === 'lanes' || variant === 'milestones' ? 1 : 0)));
const inside = variant === 'lanes' && textWidth(k.label, 12) + 16 <= w;
const showPct = (variant === 'plan' || variant === 'dependencies') && k.progress !== undefined;
return (
<g key={k.id} opacity={dim} className="transition-opacity duration-200">
<g className={anim ? 'kitch-grow-x' : undefined} style={delay(120 + n * 50)}>
<rect x={x1} y={round(y - barH / 2)} width={round(w)} height={barH} rx={variant === 'lanes' ? 6 : 4} fill={variant === 'lanes' || variant === 'milestones' ? barColor(k) : trackColor(k)} />
{variant !== 'lanes' && variant !== 'milestones' && prog > 0 && <rect x={x1} y={round(y - barH / 2)} width={round(Math.max(barH / 2, w * prog))} height={barH} rx={4} fill={barColor(k)} />}
</g>
{inside && (
<text x={x1 + 8} y={y} dy="0.34em" className={cn('pointer-events-none text-[12px] font-medium', anim && 'kitch-fade')} fill={groupIndex(k) === 0 ? 'var(--primary-foreground)' : groupIndex(k) === 1 ? 'var(--background)' : 'var(--foreground)'} style={delay(500 + n * 50)}>
{k.label}
</text>
)}
{showPct && (
<text x={x2 + 8} y={y} dy="0.34em" className={cn('text-[11px] tabular-nums', anim && 'kitch-fade')} fill={prog >= 1 ? 'var(--success-foreground)' : 'var(--muted-foreground)'} style={{ ...HALO, ...delay(500 + n * 50) }}>
{prog >= 1 ? t.done : pct.format(prog)}
</text>
)}
</g>
);
})}
{/* Today */}
{todayT !== null && (
<g className={anim ? 'kitch-fade' : undefined} style={delay(900)}>
<line x1={X(todayT) + 0.5} x2={X(todayT) + 0.5} y1={top - 8} y2={height - 6} stroke="var(--primary)" strokeWidth="1.5" strokeDasharray="4 3" />
<rect x={round(X(todayT) - textWidth(t.today, 11) / 2 - 7)} y={top - 30} width={round(textWidth(t.today, 11) + 14)} height={18} rx={5} fill="var(--primary)" />
<text x={X(todayT) + 0.5} y={top - 21} dy="0.34em" textAnchor="middle" className="text-[11px] font-semibold" fill="var(--primary-foreground)">
{t.today}
</text>
</g>
)}
</svg>
<ChartTooltip open={open} jump={jump} x={tipX} y={tipY} width={width} offset={14} title={shown !== null && tasks[shown] ? tasks[shown].label : ''} rows={shown !== null && tasks[shown] ? tipRows(shown) : []} footer={shown !== null && tasks[shown] ? afterText(tasks[shown]) || undefined : undefined} />
</ChartSurface>
</div>
</ChartRoot>
</ChartFigure>
);
}Frequent questions
How do I install Gantt chart?
With aiw add @ai-website/gantt-chart. The command puts components/ui/gantt-chart.tsx into your project, the code is yours from then on. It brings along chart and chart-figure. It needs no further npm packages.
Which variants does Gantt chart have?
4 variants, Project plan, Dependencies, Roadmap with milestones and Lanes per person. You pick one with the variant prop, for example variant="dependencies".
Can I translate the texts of Gantt chart?
Yes. Gantt chart carries 18 texts of its own in English and German, such as “Task”, “Start” and “End”. The language follows the KitProvider, single texts change through the labels prop.
Can an AI assistant build with Gantt chart?
Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Gantt chart with aiw_components and install it with aiw_add @ai-website/gantt-chart. Name the variant in your prompt, for example variant="plan", and the assistant writes your content straight into the props.