Gantt-Diagramm für React
Ein Zeitplan auf einer Zeitachse mit Gruppen, Fortschritt, Heute-Linie, Abhängigkeitspfeilen mit kritischem Pfad, Meilensteinen und Bahnen je Person. Auf dem Telefon wird jede Aufgabe ein Listeneintrag mit eigener kleiner Spur.
Ein Projektplan auf einer Zeitachse
Ein Gantt-Diagramm zeigt einen Zeitplan als Balken auf einer Zeitachse, ein Team sieht so, was wann läuft, was worauf wartet und wie weit jede Aufgabe ist. Am Telefon wird jede Aufgabe ein Listeneintrag mit eigener kleiner Spur statt eines gestauchten Diagramms.
Der „Projektplan“ ordnet Aufgaben in vier Phasen, jeder Balken bis zu seinem Fortschritt gefüllt, mit einer Summe je Phase und einer Linie für heute. „Abhängigkeiten“ zeichnet Pfeile von jeder Aufgabe zu denen, die auf sie warten, der kritische Pfad behält den Akzent, der Zeiger holt die Pfeile einer Aufgabe nach vorn. „Roadmap mit Meilensteinen“ legt vier Phasen als blasse Balken über das Jahr, Meilensteine als Rauten mit Datum, der nächste ist hervorgehoben. „Bahnen je Person“ gibt jeder Person eine Zeile mit mehreren Balken, nach Projekt gefärbt, ein Projekt in der Legende holt seine Aufgaben nach vorn.
Zeig den kritischen Pfad, er entscheidet über das Enddatum. Wähle die Skala nach der Projektlänge, Wochen für ein Quartal, Monate für ein Jahr. Setz die Heute-Linie in jede Ansicht, ein Plan ohne Jetzt liest sich schwer.
Variants
Details
Was Gantt-Diagramm mitbringt, aus Registry und Quellcode gelesen.
- Kategorie
- Charts
- Bereich
- Elements
- Variants
- 4
- Version
- 1.0.0 · seit 1.2.0
- Datei
- components/ui/gantt-chart.tsx
- Bringt mit
- utilskitchartchart-figure
- npm-Pakete
- Keine
- Sprachen
- Englisch und Deutsch, 18 eigene Texte
- Studio
- Im Code anpassen
- Mit KI bauen
- Per MCP in Claude, Cursor, Copilot und Codex
Props
Die Schnittstelle GanttChartProps aus components/ui/gantt-chart.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch. Nimmt außerdem alle Props von Omit<ChartA11y, 'label'>, ChartFigureProps an.
labeloptionalstringAccessible name of the chart. Default: the title.
variantoptionalGanttVariantplandependenciesmilestoneslanes
tasksPflichtGanttTask[]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.
Eingebaute Texte
Diese Texte schreibt Gantt-Diagramm selbst. Die Sprache folgt dem KitProvider, über die Prop labels ersetzt du einzelne.
Alle 18 Texte anzeigen
| Schlüssel | Englisch | Deutsch |
|---|---|---|
| task | Englisch Task | Deutsch Aufgabe |
| start | Englisch Start | Deutsch Beginn |
| end | Englisch End | Deutsch Ende |
| duration | Englisch Duration | Deutsch Dauer |
| progress | Englisch Progress | Deutsch Fortschritt |
| days | Englisch {n} days | Deutsch {n} Tage |
| day | Englisch 1 day | Deutsch 1 Tag |
| range | Englisch {a} to {b} | Deutsch {a} bis {b} |
| today | Englisch Today | Deutsch Heute |
| after | Englisch After {list} | Deutsch Nach {list} |
| milestone | Englisch Milestone | Deutsch Meilenstein |
| next | Englisch Next | Deutsch Als Nächstes |
| done | Englisch Done | Deutsch Erledigt |
| group | Englisch Group | Deutsch Gruppe |
| owner | Englisch Owner | Deutsch Zuständig |
| critical | Englisch Critical path | Deutsch Kritischer Pfad |
| summary | Englisch {n} tasks from {a} to {b}. | Deutsch {n} Aufgaben von {a} bis {b}. |
| of | Englisch {done} of {n} done | Deutsch {done} von {n} erledigt |
Barrierefreiheit
Aus dem Quellcode der Component abgeleitet.
- Gantt-Diagramm reagiert selbst auf die Tasten Escape, Pfeil hoch, Pfeil runter, Pfeil links, Pfeil rechts, Pos1 und Ende.
- Kommt ohne Client-State aus. Der Server schreibt sie vollständig, sie ist auch ohne JavaScript lesbar.
Bewegung
- Ohne Animationen, sie steht sofort ruhig da.
Quellcode
Die Datei, die aiw add in dein Projekt legt. Sie gehört danach dir.
Datei anzeigen, 521 Zeilen
'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>
);
}Häufige Fragen
Wie installiere ich Gantt-Diagramm?
Mit aiw add @ai-website/gantt-chart. Der Befehl legt die Datei components/ui/gantt-chart.tsx in dein Projekt, der Code gehört danach dir. Mit dabei sind chart und chart-figure. Weitere npm-Pakete braucht sie nicht.
Welche Varianten hat Gantt-Diagramm?
4 Varianten, Projektplan, Abhängigkeiten, Roadmap mit Meilensteinen und Bahnen je Person. Du wählst sie mit der Prop variant, zum Beispiel variant="dependencies".
Kann ich die Texte von Gantt-Diagramm übersetzen?
Ja. Gantt-Diagramm bringt 18 eigene Texte auf Deutsch und Englisch mit, etwa „Aufgabe“, „Beginn“ und „Ende“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.
Kann ein KI-Assistent Gantt-Diagramm einbauen?
Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini Gantt-Diagramm mit aiw_components und installieren sie mit aiw_add @ai-website/gantt-chart. Nenn im Prompt die Variante, etwa variant="plan", dann schreibt der Assistent deine Inhalte direkt in die Props.