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

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 = 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 }).reduce< { label: string; offset: number }[] >((ticks, _, index) => { const day = index * stepDays; const date = new Date(start.getTime() + day * MS_PER_DAY); if (date.getTime() <= end.getTime()) { ticks.push({ label: formatter(date), offset: day }); } return ticks; }, []); } 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";

Dependencies

  • @vllnt/ui@^0.2.1