Skip to content
UI Kit
Accent color

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.

4 variantsOne of 10 components in Charts

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

Project planNew in 1.2

Tasks in four phases, each bar filled to its progress, a summary per phase and a line for today.

DependenciesNew in 1.2

Arrows lead from each task to the ones that wait for it. The critical path keeps the accent, the pointer brings the arrows of one task forward.

Roadmap with milestonesNew in 1.2

Four phases as pale bars over the year, milestones as diamonds with their date. The next one is called out.

Lanes per personNew in 1.2

One row per person, several bars in a row, colored by project. A project in the legend brings its tasks forward.

Details

What Gantt chart brings along, read from the registry and the source.

Category
Charts
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

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.

  • labeloptional
    string

    Accessible name of the chart. Default: the title.

  • variantoptional
    GanttVariant

    plandependenciesmilestoneslanes

  • tasksrequired
    GanttTask[]
  • todayoptional
    string

    The day of the today line, "2026-10-14". Leave out for none.

  • criticaloptional
    string[]

    dependencies: ids of the tasks on the critical path

  • groupsoptional
    string[]

    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
taskEnglish TaskGerman Aufgabe
startEnglish StartGerman Beginn
endEnglish EndGerman Ende
durationEnglish DurationGerman Dauer
progressEnglish ProgressGerman Fortschritt
daysEnglish {n} daysGerman {n} Tage
dayEnglish 1 dayGerman 1 Tag
rangeEnglish {a} to {b}German {a} bis {b}
todayEnglish TodayGerman Heute
afterEnglish After {list}German Nach {list}
milestoneEnglish MilestoneGerman Meilenstein
nextEnglish NextGerman Als Nächstes
doneEnglish DoneGerman Erledigt
groupEnglish GroupGerman Gruppe
ownerEnglish OwnerGerman Zuständig
criticalEnglish Critical pathGerman Kritischer Pfad
summaryEnglish {n} tasks from {a} to {b}.German {n} Aufgaben von {a} bis {b}.
ofEnglish {done} of {n} doneGerman {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
components/ui/gantt-chart.tsx
'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.

Often searched

Jump to

Copied to the clipboard