Gantt Chart

Project timeline with task bars, progress overlays, milestones, and a today indicator across day/week/month/quarter scales.

Report a bug

Preview

Switch between light and dark to inspect the embedded Storybook preview.

Installation

pnpm dlx shadcn@latest add https://ui.vllnt.ai/r/gantt-chart.json
bash

Storybook

Explore all variants, controls, and accessibility checks in the interactive Storybook playground.

View in Storybook

Code

"use client";

import {
  type ComponentPropsWithoutRef,
  forwardRef,
  type ReactNode,
  useMemo,
} from "react";

import { cn } from "../../lib/utils";

const MS_PER_DAY = 24 * 60 * 60 * 1000;
const DEFAULT_LOCALE = "en-US";

/**
 * Color theme for a {@link GanttTask}'s bar.
 *
 * @public
 */
export type GanttColor =
  | "amber"
  | "blue"
  | "emerald"
  | "neutral"
  | "purple"
  | "red"
  | "rose";

const COLOR_CLASSES: Record<GanttColor, { bar: string; progress: string }> = {
  amber: {
    bar: "bg-amber-500/30",
    progress: "bg-amber-600 dark:bg-amber-500",
  },
  blue: {
    bar: "bg-blue-500/30",
    progress: "bg-blue-600 dark:bg-blue-500",
  },
  emerald: {
    bar: "bg-emerald-500/30",
    progress: "bg-emerald-600 dark:bg-emerald-500",
  },
  neutral: {
    bar: "bg-muted",
    progress: "bg-muted-foreground",
  },
  purple: {
    bar: "bg-purple-500/30",
    progress: "bg-purple-600 dark:bg-purple-500",
  },
  red: {
    bar: "bg-red-500/30",
    progress: "bg-red-600 dark:bg-red-500",
  },
  rose: {
    bar: "bg-rose-500/30",
    progress: "bg-rose-600 dark:bg-rose-500",
  },
};

/**
 * Time-axis scale.
 *
 * @public
 */
export type GanttScale = "day" | "month" | "quarter" | "week";

/**
 * Localizable strings.
 *
 * @public
 */
export type GanttChartLabels = {
  /** Aria-label prefix for milestone diamonds. Defaults to `"Milestone"`. */
  milestone?: string;
  /** Caption for the today line. Defaults to `"Today"`. */
  today?: string;
};

const DEFAULT_LABELS = {
  milestone: "Milestone",
  today: "Today",
} as const satisfies Required<GanttChartLabels>;

/**
 * One task bar inside a {@link GanttGroup}.
 *
 * @public
 */
export type GanttTask = {
  /** Optional assignee label rendered next to the task title. */
  assignee?: ReactNode;
  /** Optional color theme. Defaults to `"blue"`. */
  color?: GanttColor;
  /** End date (inclusive). ISO string or `Date`. */
  end: Date | string;
  /** Stable identifier. */
  id: string;
  /** Optional progress percentage 0–100. */
  progress?: number;
  /** Start date (inclusive). ISO string or `Date`. */
  start: Date | string;
  /** Task title. */
  title: ReactNode;
};

/**
 * Group of related {@link GanttTask}s.
 *
 * @public
 */
export type GanttGroup = {
  /** Stable identifier. */
  id: string;
  /** Group display name. */
  name: ReactNode;
  /** Tasks rendered in this group. */
  tasks: GanttTask[];
};

/**
 * Vertical milestone marker rendered on the timeline.
 *
 * @public
 */
export type GanttMilestone = {
  /** Date the milestone falls on. */
  date: Date | string;
  /** Stable identifier. */
  id: string;
  /** Milestone title. */
  title: ReactNode;
};

/**
 * Props for {@link GanttChart}.
 *
 * @public
 */
export type GanttChartProps = {
  /** End of the visible time window. */
  endDate: Date | string;
  /** Task groups, rendered in order. */
  groups: GanttGroup[];
  /** Localizable strings. */
  labels?: GanttChartLabels;
  /** BCP-47 locale tag. Defaults to `"en-US"`. */
  locale?: string;
  /** Optional milestone markers. */
  milestones?: GanttMilestone[];
  /** Optional override for the "today" date. Defaults to the current date. */
  now?: Date | string;
  /** Time-axis scale. Drives the tick interval and label format. Defaults to `"month"`. */
  scale?: GanttScale;
  /** Start of the visible time window. */
  startDate: Date | string;
  /** Width allocated to the task name column (left side). Defaults to `200`. */
  taskColumnWidth?: number;
} & ComponentPropsWithoutRef<"div">;

function toDate(value: Date | string): Date {
  return value instanceof Date ? new Date(value.getTime()) : new Date(value);
}

function clamp(value: number, min: number, max: number): number {
  return Math.min(Math.max(value, min), max);
}

function diffInDays(later: Date, earlier: Date): number {
  return (later.getTime() - earlier.getTime()) / MS_PER_DAY;
}

const TICK_FORMATTER_CACHE = new Map<string, Intl.DateTimeFormat>();
function getTickDateTimeFormatter(
  locale: string,
  scale: "day" | "month" | "week",
): Intl.DateTimeFormat {
  const key = `${locale}|${scale}`;
  let formatter = TICK_FORMATTER_CACHE.get(key);
  if (!formatter) {
    const options: Intl.DateTimeFormatOptions =
      scale === "month"
        ? { month: "short", year: "numeric" }
        : { day: "2-digit", month: "short" };
    formatter = new Intl.DateTimeFormat(locale, options);
    TICK_FORMATTER_CACHE.set(key, formatter);
  }
  return formatter;
}

function buildTickFormatter(
  scale: GanttScale,
  locale: string,
): (date: Date) => string {
  switch (scale) {
    case "day":
      return getTickDateTimeFormatter(locale, "day").format;
    case "week":
      return getTickDateTimeFormatter(locale, "week").format;
    case "month":
      return getTickDateTimeFormatter(locale, "month").format;
    case "quarter":
      return (date: Date) => {
        const quarter = Math.floor(date.getMonth() / 3) + 1;
        return `Q${quarter.toString()} ${date.getFullYear().toString()}`;
      };
  }
}

function getTickStep(scale: GanttScale): number {
  switch (scale) {
    case "day":
      return 1;
    case "week":
      return 7;
    case "month":
      return 30;
    case "quarter":
      return 91;
  }
}

type ChartGeometry = {
  end: Date;
  pxPerDay: number;
  start: Date;
  ticks: { label: string; offset: number }[];
  totalDays: number;
};

type TicksInput = {
  end: Date;
  locale: string;
  scale: GanttScale;
  start: Date;
  totalDays: number;
};

function buildTicks(input: TicksInput): { label: string; offset: number }[] {
  const { end, locale, scale, start, totalDays } = input;
  const formatter = buildTickFormatter(scale, locale);
  const stepDays = getTickStep(scale);
  const tickCount = Math.floor(totalDays / stepDays);
  return Array.from({ length: tickCount + 1 })
    .map((_, index) => {
      const day = index * stepDays;
      return {
        date: new Date(start.getTime() + day * MS_PER_DAY),
        offset: day,
      };
    })
    .filter((tick) => tick.date.getTime() <= end.getTime())
    .map((tick) => ({ label: formatter(tick.date), offset: tick.offset }));
}

type GeometryOptions = {
  endDate: Date | string;
  locale: string;
  scale: GanttScale;
  startDate: Date | string;
};

function useChartGeometry(options: GeometryOptions): ChartGeometry {
  const { endDate, locale, scale, startDate } = options;
  return useMemo<ChartGeometry>(() => {
    const start = toDate(startDate);
    const end = toDate(endDate);
    const totalDays = Math.max(1, diffInDays(end, start));
    const ticks = buildTicks({ end, locale, scale, start, totalDays });
    return {
      end,
      pxPerDay: 1 / totalDays,
      start,
      ticks,
      totalDays,
    };
  }, [endDate, locale, scale, startDate]);
}

type TaskBarProps = {
  geometry: ChartGeometry;
  task: GanttTask;
};

function TaskBar({ geometry, task }: TaskBarProps): ReactNode {
  const start = toDate(task.start);
  const end = toDate(task.end);
  const offsetDays = diffInDays(start, geometry.start);
  const durationDays = Math.max(0.5, diffInDays(end, start));
  const leftRatio = clamp(offsetDays / geometry.totalDays, 0, 1);
  const widthRatio = clamp(durationDays / geometry.totalDays, 0, 1 - leftRatio);
  const palette = COLOR_CLASSES[task.color ?? "blue"];
  const progress = clamp(task.progress ?? 0, 0, 100);
  const ariaLabel =
    typeof task.title === "string"
      ? `${task.title} from ${start.toISOString().slice(0, 10)} to ${end.toISOString().slice(0, 10)}, ${progress.toString()} percent complete`
      : undefined;
  return (
    <div
      aria-label={ariaLabel}
      aria-valuemax={100}
      aria-valuemin={0}
      aria-valuenow={progress}
      className={cn(
        "absolute top-1.5 flex h-5 items-center overflow-hidden rounded-md ring-1 ring-border",
        palette.bar,
      )}
      data-task-id={task.id}
      role="progressbar"
      style={{
        left: `${(leftRatio * 100).toString()}%`,
        width: `${(widthRatio * 100).toString()}%`,
      }}
    >
      <span
        aria-hidden="true"
        className={cn("h-full rounded-md", palette.progress)}
        style={{ width: `${progress.toString()}%` }}
      />
    </div>
  );
}

type MilestoneMarkerProps = {
  geometry: ChartGeometry;
  label: string;
  milestone: GanttMilestone;
};

function MilestoneMarker({
  geometry,
  label,
  milestone,
}: MilestoneMarkerProps): ReactNode {
  const date = toDate(milestone.date);
  const offsetDays = diffInDays(date, geometry.start);
  if (offsetDays < 0 || offsetDays > geometry.totalDays) return null;
  const leftRatio = offsetDays / geometry.totalDays;
  const titleText = typeof milestone.title === "string" ? milestone.title : "";
  return (
    <div
      aria-label={`${label}: ${titleText}`}
      className="absolute top-0 z-10 -ml-1.5 flex flex-col items-center"
      data-milestone-id={milestone.id}
      style={{ left: `${(leftRatio * 100).toString()}%` }}
    >
      <div
        aria-hidden="true"
        className="size-3 rotate-45 bg-amber-500 ring-2 ring-background"
      />
      {titleText ? (
        <span className="mt-0.5 whitespace-nowrap rounded bg-amber-500/20 px-1 text-[10px] font-medium text-amber-900 dark:text-amber-200">
          {titleText}
        </span>
      ) : null}
    </div>
  );
}

type TodayLineProps = {
  geometry: ChartGeometry;
  label: string;
  now: Date;
};

function TodayLine({ geometry, label, now }: TodayLineProps): ReactNode {
  const offsetDays = diffInDays(now, geometry.start);
  if (offsetDays < 0 || offsetDays > geometry.totalDays) return null;
  const leftRatio = offsetDays / geometry.totalDays;
  return (
    <div
      aria-label={label}
      className="pointer-events-none absolute inset-y-0 z-10"
      style={{ left: `${(leftRatio * 100).toString()}%` }}
    >
      <div
        aria-hidden="true"
        className="absolute inset-y-0 w-0.5 -translate-x-1/2 bg-destructive"
      />
      <span className="absolute -top-5 -translate-x-1/2 whitespace-nowrap rounded bg-destructive/15 px-1 text-[10px] font-semibold uppercase tracking-wide text-destructive">
        {label}
      </span>
    </div>
  );
}

type AxisProps = {
  geometry: ChartGeometry;
};

function Axis({ geometry }: AxisProps): ReactNode {
  return (
    <div className="relative flex h-7 items-end border-b border-border text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
      {geometry.ticks.map((tick) => (
        <span
          className="absolute -translate-x-1/2 px-1"
          key={tick.offset}
          style={{
            left: `${((tick.offset / geometry.totalDays) * 100).toString()}%`,
          }}
        >
          {tick.label}
        </span>
      ))}
    </div>
  );
}

type TaskRowProps = {
  geometry: ChartGeometry;
  task: GanttTask;
};

function TaskRow({ geometry, task }: TaskRowProps): ReactNode {
  return (
    <div className="relative flex h-8 items-center border-b border-border/40">
      <TaskBar geometry={geometry} task={task} />
    </div>
  );
}

type LeftColumnProps = {
  groups: GanttGroup[];
  taskColumnWidth: number;
};

function LeftColumn({ groups, taskColumnWidth }: LeftColumnProps): ReactNode {
  return (
    <div
      className="flex flex-col border-r border-border bg-background"
      style={{
        minWidth: `${taskColumnWidth.toString()}px`,
        width: `${taskColumnWidth.toString()}px`,
      }}
    >
      <div className="h-7 border-b border-border" />
      {groups.map((group) => (
        <div className="flex flex-col" key={group.id}>
          <div className="flex h-7 items-center border-b border-border/60 px-3 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
            {group.name}
          </div>
          {group.tasks.map((task) => (
            <div
              className="flex h-8 items-center justify-between gap-2 border-b border-border/40 px-3 text-sm text-foreground"
              key={task.id}
            >
              <span className="truncate">{task.title}</span>
              {task.assignee ? (
                <span className="text-xs text-muted-foreground">
                  {task.assignee}
                </span>
              ) : null}
            </div>
          ))}
        </div>
      ))}
    </div>
  );
}

type TimelineColumnProps = {
  geometry: ChartGeometry;
  groups: GanttGroup[];
  labels: Required<GanttChartLabels>;
  milestones: GanttMilestone[];
  now: Date;
};

function TimelineColumn({
  geometry,
  groups,
  labels,
  milestones,
  now,
}: TimelineColumnProps): ReactNode {
  return (
    <div className="relative min-w-0 flex-1">
      <Axis geometry={geometry} />
      <div className="relative">
        {groups.map((group) => (
          <div className="flex flex-col" key={group.id}>
            <div className="h-7 border-b border-border/60 bg-muted/20" />
            {group.tasks.map((task) => (
              <TaskRow geometry={geometry} key={task.id} task={task} />
            ))}
          </div>
        ))}
        {milestones.map((milestone) => (
          <MilestoneMarker
            geometry={geometry}
            key={milestone.id}
            label={labels.milestone}
            milestone={milestone}
          />
        ))}
        <TodayLine geometry={geometry} label={labels.today} now={now} />
      </div>
    </div>
  );
}

/**
 * Pure-SVG-free Gantt chart for project planning. Renders task bars with
 * progress overlays, milestone diamonds, and a today indicator across a
 * configurable time scale (day / week / month / quarter). The left column
 * shows group headers and task names; the right column is the timeline.
 *
 * Drag-to-edit, dependency arrows, critical-path highlighting, and
 * virtualization for large datasets are intentionally **out of scope** —
 * the consumer drives those externally and feeds data through `groups`.
 *
 * @example
 * ```tsx
 * <GanttChart
 *   startDate="2026-01-01"
 *   endDate="2026-12-31"
 *   scale="month"
 *   groups={[
 *     {
 *       id: "phase-1",
 *       name: "Phase 1",
 *       tasks: [
 *         { id: "design", title: "Design system", start: "2026-01-15", end: "2026-02-28", progress: 100, color: "blue" },
 *         { id: "core", title: "Core components", start: "2026-02-01", end: "2026-04-15", progress: 65, color: "emerald" },
 *       ],
 *     },
 *   ]}
 *   milestones={[{ id: "v1", date: "2026-04-15", title: "v1.0" }]}
 * />
 * ```
 *
 * @public
 */
export const GanttChart = forwardRef<HTMLDivElement, GanttChartProps>(
  (props, ref) => {
    const {
      className,
      endDate,
      groups,
      labels,
      locale = DEFAULT_LOCALE,
      milestones = [],
      now,
      scale = "month",
      startDate,
      taskColumnWidth = 200,
      ...rest
    } = props;
    const resolvedLabels = useMemo(
      () => ({ ...DEFAULT_LABELS, ...labels }),
      [labels],
    );
    const geometry = useChartGeometry({ endDate, locale, scale, startDate });
    const nowDate = useMemo(() => (now ? toDate(now) : new Date()), [now]);

    return (
      <div
        className={cn(
          "flex w-full overflow-x-auto rounded-2xl border bg-background text-foreground",
          className,
        )}
        ref={ref}
        {...rest}
      >
        <LeftColumn groups={groups} taskColumnWidth={taskColumnWidth} />
        <TimelineColumn
          geometry={geometry}
          groups={groups}
          labels={resolvedLabels}
          milestones={milestones}
          now={nowDate}
        />
      </div>
    );
  },
);
GanttChart.displayName = "GanttChart";
typescript

Dependencies

  • @vllnt/ui@^0.2.1