Gantt
Professional project timeline visualization with interactive scheduling capabilities. Built for React and Next.js teams who need powerful Gantt charts with TypeScript safety and modern shadcn/ui design.
'use client';import { faker } from '@faker-js/faker';import { GanttCreateMarkerTrigger, GanttFeatureItem, GanttFeatureList, GanttFeatureListGroup, GanttHeader, GanttMarker, GanttProvider, GanttSidebar, GanttSidebarGroup, GanttSidebarItem, GanttTimeline, GanttToday,} from '@/components/ui/shadcn-io/gantt';import groupBy from 'lodash.groupby';import { EyeIcon, LinkIcon, TrashIcon } from 'lucide-react';import { useState } from 'react';import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger,} from '@/components/ui/context-menu';const capitalize = (str: string) => str.charAt(0).toUpperCase() + str.slice(1);const statuses = [ { id: faker.string.uuid(), name: 'Planned', color: '#6B7280' }, { id: faker.string.uuid(), name: 'In Progress', color: '#F59E0B' }, { id: faker.string.uuid(), name: 'Done', color: '#10B981' },];const users = Array.from({ length: 4 }) .fill(null) .map(() => ({ id: faker.string.uuid(), name: faker.person.fullName(), image: faker.image.avatar(), }));const exampleGroups = Array.from({ length: 6 }) .fill(null) .map(() => ({ id: faker.string.uuid(), name: capitalize(faker.company.buzzPhrase()), }));const exampleProducts = Array.from({ length: 4 }) .fill(null) .map(() => ({ id: faker.string.uuid(), name: capitalize(faker.company.buzzPhrase()), }));const exampleInitiatives = Array.from({ length: 2 }) .fill(null) .map(() => ({ id: faker.string.uuid(), name: capitalize(faker.company.buzzPhrase()), }));const exampleReleases = Array.from({ length: 3 }) .fill(null) .map(() => ({ id: faker.string.uuid(), name: capitalize(faker.company.buzzPhrase()), }));const exampleFeatures = Array.from({ length: 20 }) .fill(null) .map(() => ({ id: faker.string.uuid(), name: capitalize(faker.company.buzzPhrase()), startAt: faker.date.past({ years: 0.5, refDate: new Date() }), endAt: faker.date.future({ years: 0.5, refDate: new Date() }), status: faker.helpers.arrayElement(statuses), owner: faker.helpers.arrayElement(users), group: faker.helpers.arrayElement(exampleGroups), product: faker.helpers.arrayElement(exampleProducts), initiative: faker.helpers.arrayElement(exampleInitiatives), release: faker.helpers.arrayElement(exampleReleases), }));const exampleMarkers = Array.from({ length: 6 }) .fill(null) .map(() => ({ id: faker.string.uuid(), date: faker.date.past({ years: 0.5, refDate: new Date() }), label: capitalize(faker.company.buzzPhrase()), className: faker.helpers.arrayElement([ 'bg-blue-100 text-blue-900', 'bg-green-100 text-green-900', 'bg-purple-100 text-purple-900', 'bg-red-100 text-red-900', 'bg-orange-100 text-orange-900', 'bg-teal-100 text-teal-900', ]), }));const Example = () => { const [features, setFeatures] = useState(exampleFeatures); const groupedFeatures = groupBy(features, 'group.name'); const sortedGroupedFeatures = Object.fromEntries( Object.entries(groupedFeatures).sort(([nameA], [nameB]) => nameA.localeCompare(nameB) ) ); const handleViewFeature = (id: string) => console.log(`Feature selected: ${id}`); const handleCopyLink = (id: string) => console.log(`Copy link: ${id}`); const handleRemoveFeature = (id: string) => setFeatures((prev) => prev.filter((feature) => feature.id !== id)); const handleRemoveMarker = (id: string) => console.log(`Remove marker: ${id}`); const handleCreateMarker = (date: Date) => console.log(`Create marker: ${date.toISOString()}`); const handleMoveFeature = (id: string, startAt: Date, endAt: Date | null) => { if (!endAt) { return; } setFeatures((prev) => prev.map((feature) => feature.id === id ? { ...feature, startAt, endAt } : feature ) ); console.log(`Move feature: ${id} from ${startAt} to ${endAt}`); }; const handleAddFeature = (date: Date) => console.log(`Add feature: ${date.toISOString()}`); return ( <GanttProvider className="border" onAddItem={handleAddFeature} range="monthly" zoom={100} > <GanttSidebar> {Object.entries(sortedGroupedFeatures).map(([group, features]) => ( <GanttSidebarGroup key={group} name={group}> {features.map((feature) => ( <GanttSidebarItem feature={feature} key={feature.id} onSelectItem={handleViewFeature} /> ))} </GanttSidebarGroup> ))} </GanttSidebar> <GanttTimeline> <GanttHeader /> <GanttFeatureList> {Object.entries(sortedGroupedFeatures).map(([group, features]) => ( <GanttFeatureListGroup key={group}> {features.map((feature) => ( <div className="flex" key={feature.id}> <ContextMenu> <ContextMenuTrigger asChild> <button onClick={() => handleViewFeature(feature.id)} type="button" > <GanttFeatureItem onMove={handleMoveFeature} {...feature} > <p className="flex-1 truncate text-xs"> {feature.name} </p> {feature.owner && ( <Avatar className="h-4 w-4"> <AvatarImage src={feature.owner.image} /> <AvatarFallback> {feature.owner.name?.slice(0, 2)} </AvatarFallback> </Avatar> )} </GanttFeatureItem> </button> </ContextMenuTrigger> <ContextMenuContent> <ContextMenuItem className="flex items-center gap-2" onClick={() => handleViewFeature(feature.id)} > <EyeIcon className="text-muted-foreground" size={16} /> View feature </ContextMenuItem> <ContextMenuItem className="flex items-center gap-2" onClick={() => handleCopyLink(feature.id)} > <LinkIcon className="text-muted-foreground" size={16} /> Copy link </ContextMenuItem> <ContextMenuItem className="flex items-center gap-2 text-destructive" onClick={() => handleRemoveFeature(feature.id)} > <TrashIcon size={16} /> Remove from roadmap </ContextMenuItem> </ContextMenuContent> </ContextMenu> </div> ))} </GanttFeatureListGroup> ))} </GanttFeatureList> {exampleMarkers.map((marker) => ( <GanttMarker key={marker.id} {...marker} onRemove={handleRemoveMarker} /> ))} <GanttToday /> <GanttCreateMarkerTrigger onCreateMarker={handleCreateMarker} /> </GanttTimeline> </GanttProvider> );};export default Example;
'use client';import { DndContext, MouseSensor, useDraggable, useSensor,} from '@dnd-kit/core';import { restrictToHorizontalAxis } from '@dnd-kit/modifiers';import { useMouse, useThrottle, useWindowScroll } from '@uidotdev/usehooks';import { addDays, addMonths, differenceInDays, differenceInHours, differenceInMonths, endOfDay, endOfMonth, format, formatDate, formatDistance, getDate, getDaysInMonth, isSameDay, startOfDay, startOfMonth,} from 'date-fns';import { atom, useAtom } from 'jotai';import throttle from 'lodash.throttle';import { PlusIcon, TrashIcon } from 'lucide-react';import type { CSSProperties, FC, KeyboardEventHandler, MouseEventHandler, ReactNode, RefObject,} from 'react';import { createContext, memo, useCallback, useContext, useEffect, useId, useMemo, useRef, useState,} from 'react';import { Card } from '@/components/ui/card';import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger,} from '@/components/ui/context-menu';import { cn } from '@/lib/utils';const draggingAtom = atom(false);const scrollXAtom = atom(0);export const useGanttDragging = () => useAtom(draggingAtom);export const useGanttScrollX = () => useAtom(scrollXAtom);export type GanttStatus = { id: string; name: string; color: string;};export type GanttFeature = { id: string; name: string; startAt: Date; endAt: Date; status: GanttStatus; lane?: string; // Optional: features with the same lane will share a row};export type GanttMarkerProps = { id: string; date: Date; label: string;};export type Range = 'daily' | 'monthly' | 'quarterly';export type TimelineData = { year: number; quarters: { months: { days: number; }[]; }[];}[];export type GanttContextProps = { zoom: number; range: Range; columnWidth: number; sidebarWidth: number; headerHeight: number; rowHeight: number; onAddItem: ((date: Date) => void) | undefined; placeholderLength: number; timelineData: TimelineData; ref: RefObject<HTMLDivElement | null> | null; scrollToFeature?: (feature: GanttFeature) => void;};const getsDaysIn = (range: Range) => { // For when range is daily let fn = (_date: Date) => 1; if (range === 'monthly' || range === 'quarterly') { fn = getDaysInMonth; } return fn;};const getDifferenceIn = (range: Range) => { let fn = differenceInDays; if (range === 'monthly' || range === 'quarterly') { fn = differenceInMonths; } return fn;};const getInnerDifferenceIn = (range: Range) => { let fn = differenceInHours; if (range === 'monthly' || range === 'quarterly') { fn = differenceInDays; } return fn;};const getStartOf = (range: Range) => { let fn = startOfDay; if (range === 'monthly' || range === 'quarterly') { fn = startOfMonth; } return fn;};const getEndOf = (range: Range) => { let fn = endOfDay; if (range === 'monthly' || range === 'quarterly') { fn = endOfMonth; } return fn;};const getAddRange = (range: Range) => { let fn = addDays; if (range === 'monthly' || range === 'quarterly') { fn = addMonths; } return fn;};const getDateByMousePosition = (context: GanttContextProps, mouseX: number) => { const timelineStartDate = new Date(context.timelineData[0].year, 0, 1); const columnWidth = (context.columnWidth * context.zoom) / 100; const offset = Math.floor(mouseX / columnWidth); const daysIn = getsDaysIn(context.range); const addRange = getAddRange(context.range); const month = addRange(timelineStartDate, offset); const daysInMonth = daysIn(month); const pixelsPerDay = Math.round(columnWidth / daysInMonth); const dayOffset = Math.floor((mouseX % columnWidth) / pixelsPerDay); const actualDate = addDays(month, dayOffset); return actualDate;};const createInitialTimelineData = (today: Date) => { const data: TimelineData = []; data.push( { year: today.getFullYear() - 1, quarters: new Array(4).fill(null) }, { year: today.getFullYear(), quarters: new Array(4).fill(null) }, { year: today.getFullYear() + 1, quarters: new Array(4).fill(null) } ); for (const yearObj of data) { yearObj.quarters = new Array(4).fill(null).map((_, quarterIndex) => ({ months: new Array(3).fill(null).map((_, monthIndex) => { const month = quarterIndex * 3 + monthIndex; return { days: getDaysInMonth(new Date(yearObj.year, month, 1)), }; }), })); } return data;};const getOffset = ( date: Date, timelineStartDate: Date, context: GanttContextProps) => { const parsedColumnWidth = (context.columnWidth * context.zoom) / 100; const differenceIn = getDifferenceIn(context.range); const startOf = getStartOf(context.range); const fullColumns = differenceIn(startOf(date), timelineStartDate); if (context.range === 'daily') { return parsedColumnWidth * fullColumns; } const partialColumns = date.getDate(); const daysInMonth = getDaysInMonth(date); const pixelsPerDay = parsedColumnWidth / daysInMonth; return fullColumns * parsedColumnWidth + partialColumns * pixelsPerDay;};const getWidth = ( startAt: Date, endAt: Date | null, context: GanttContextProps) => { const parsedColumnWidth = (context.columnWidth * context.zoom) / 100; if (!endAt) { return parsedColumnWidth * 2; } const differenceIn = getDifferenceIn(context.range); if (context.range === 'daily') { const delta = differenceIn(endAt, startAt); return parsedColumnWidth * (delta ? delta : 1); } const daysInStartMonth = getDaysInMonth(startAt); const pixelsPerDayInStartMonth = parsedColumnWidth / daysInStartMonth; if (isSameDay(startAt, endAt)) { return pixelsPerDayInStartMonth; } const innerDifferenceIn = getInnerDifferenceIn(context.range); const startOf = getStartOf(context.range); if (isSameDay(startOf(startAt), startOf(endAt))) { return innerDifferenceIn(endAt, startAt) * pixelsPerDayInStartMonth; } const startRangeOffset = daysInStartMonth - getDate(startAt); const endRangeOffset = getDate(endAt); const fullRangeOffset = differenceIn(startOf(endAt), startOf(startAt)); const daysInEndMonth = getDaysInMonth(endAt); const pixelsPerDayInEndMonth = parsedColumnWidth / daysInEndMonth; return ( (fullRangeOffset - 1) * parsedColumnWidth + startRangeOffset * pixelsPerDayInStartMonth + endRangeOffset * pixelsPerDayInEndMonth );};const calculateInnerOffset = ( date: Date, range: Range, columnWidth: number) => { const startOf = getStartOf(range); const endOf = getEndOf(range); const differenceIn = getInnerDifferenceIn(range); const startOfRange = startOf(date); const endOfRange = endOf(date); const totalRangeDays = differenceIn(endOfRange, startOfRange); const dayOfMonth = date.getDate(); return (dayOfMonth / totalRangeDays) * columnWidth;};const GanttContext = createContext<GanttContextProps>({ zoom: 100, range: 'monthly', columnWidth: 50, headerHeight: 60, sidebarWidth: 300, rowHeight: 36, onAddItem: undefined, placeholderLength: 2, timelineData: [], ref: null, scrollToFeature: undefined,});export type GanttContentHeaderProps = { renderHeaderItem: (index: number) => ReactNode; title: string; columns: number;};export const GanttContentHeader: FC<GanttContentHeaderProps> = ({ title, columns, renderHeaderItem,}) => { const id = useId(); return ( <div className="sticky top-0 z-20 grid w-full shrink-0 bg-backdrop/90 backdrop-blur-sm" style={{ height: 'var(--gantt-header-height)' }} > <div> <div className="sticky inline-flex whitespace-nowrap px-3 py-2 text-muted-foreground text-xs" style={{ left: 'var(--gantt-sidebar-width)', }} > <p>{title}</p> </div> </div> <div className="grid w-full" style={{ gridTemplateColumns: `repeat(${columns}, var(--gantt-column-width))`, }} > {Array.from({ length: columns }).map((_, index) => ( <div className="shrink-0 border-border/50 border-b py-1 text-center text-xs" key={`${id}-${index}`} > {renderHeaderItem(index)} </div> ))} </div> </div> );};const DailyHeader: FC = () => { const gantt = useContext(GanttContext); return gantt.timelineData.map((year) => year.quarters .flatMap((quarter) => quarter.months) .map((month, index) => ( <div className="relative flex flex-col" key={`${year.year}-${index}`}> <GanttContentHeader columns={month.days} renderHeaderItem={(item: number) => ( <div className="flex items-center justify-center gap-1"> <p> {format(addDays(new Date(year.year, index, 1), item), 'd')} </p> <p className="text-muted-foreground"> {format( addDays(new Date(year.year, index, 1), item), 'EEEEE' )} </p> </div> )} title={format(new Date(year.year, index, 1), 'MMMM yyyy')} /> <GanttColumns columns={month.days} isColumnSecondary={(item: number) => [0, 6].includes( addDays(new Date(year.year, index, 1), item).getDay() ) } /> </div> )) );};const MonthlyHeader: FC = () => { const gantt = useContext(GanttContext); return gantt.timelineData.map((year) => ( <div className="relative flex flex-col" key={year.year}> <GanttContentHeader columns={year.quarters.flatMap((quarter) => quarter.months).length} renderHeaderItem={(item: number) => ( <p>{format(new Date(year.year, item, 1), 'MMM')}</p> )} title={`${year.year}`} /> <GanttColumns columns={year.quarters.flatMap((quarter) => quarter.months).length} /> </div> ));};const QuarterlyHeader: FC = () => { const gantt = useContext(GanttContext); return gantt.timelineData.map((year) => year.quarters.map((quarter, quarterIndex) => ( <div className="relative flex flex-col" key={`${year.year}-${quarterIndex}`} > <GanttContentHeader columns={quarter.months.length} renderHeaderItem={(item: number) => ( <p> {format(new Date(year.year, quarterIndex * 3 + item, 1), 'MMM')} </p> )} title={`Q${quarterIndex + 1} ${year.year}`} /> <GanttColumns columns={quarter.months.length} /> </div> )) );};const headers: Record<Range, FC> = { daily: DailyHeader, monthly: MonthlyHeader, quarterly: QuarterlyHeader,};export type GanttHeaderProps = { className?: string;};export const GanttHeader: FC<GanttHeaderProps> = ({ className }) => { const gantt = useContext(GanttContext); const Header = headers[gantt.range]; return ( <div className={cn( '-space-x-px flex h-full w-max divide-x divide-border/50', className )} > <Header /> </div> );};export type GanttSidebarItemProps = { feature: GanttFeature; onSelectItem?: (id: string) => void; className?: string;};export const GanttSidebarItem: FC<GanttSidebarItemProps> = ({ feature, onSelectItem, className,}) => { const gantt = useContext(GanttContext); const tempEndAt = feature.endAt && isSameDay(feature.startAt, feature.endAt) ? addDays(feature.endAt, 1) : feature.endAt; const duration = tempEndAt ? formatDistance(feature.startAt, tempEndAt) : `${formatDistance(feature.startAt, new Date())} so far`; const handleClick: MouseEventHandler<HTMLDivElement> = (event) => { if (event.target === event.currentTarget) { // Scroll to the feature in the timeline gantt.scrollToFeature?.(feature); // Call the original onSelectItem callback onSelectItem?.(feature.id); } }; const handleKeyDown: KeyboardEventHandler<HTMLDivElement> = (event) => { if (event.key === 'Enter') { // Scroll to the feature in the timeline gantt.scrollToFeature?.(feature); // Call the original onSelectItem callback onSelectItem?.(feature.id); } }; return ( <div className={cn( 'relative flex items-center gap-2.5 p-2.5 text-xs hover:bg-secondary', className )} key={feature.id} onClick={handleClick} onKeyDown={handleKeyDown} // biome-ignore lint/a11y/useSemanticElements: "This is a clickable item" role="button" style={{ height: 'var(--gantt-row-height)', }} tabIndex={0} > {/* <Checkbox onCheckedChange={handleCheck} className="shrink-0" /> */} <div className="pointer-events-none h-2 w-2 shrink-0 rounded-full" style={{ backgroundColor: feature.status.color, }} /> <p className="pointer-events-none flex-1 truncate text-left font-medium"> {feature.name} </p> <p className="pointer-events-none text-muted-foreground">{duration}</p> </div> );};export const GanttSidebarHeader: FC = () => ( <div className="sticky top-0 z-10 flex shrink-0 items-end justify-between gap-2.5 border-border/50 border-b bg-backdrop/90 p-2.5 font-medium text-muted-foreground text-xs backdrop-blur-sm" style={{ height: 'var(--gantt-header-height)' }} > {/* <Checkbox className="shrink-0" /> */} <p className="flex-1 truncate text-left">Issues</p> <p className="shrink-0">Duration</p> </div>);export type GanttSidebarGroupProps = { children: ReactNode; name: string; className?: string;};export const GanttSidebarGroup: FC<GanttSidebarGroupProps> = ({ children, name, className,}) => ( <div className={className}> <p className="w-full truncate p-2.5 text-left font-medium text-muted-foreground text-xs" style={{ height: 'var(--gantt-row-height)' }} > {name} </p> <div className="divide-y divide-border/50">{children}</div> </div>);export type GanttSidebarProps = { children: ReactNode; className?: string;};export const GanttSidebar: FC<GanttSidebarProps> = ({ children, className,}) => ( <div className={cn( 'sticky left-0 z-30 h-max min-h-full overflow-clip border-border/50 border-r bg-background/90 backdrop-blur-md', className )} data-roadmap-ui="gantt-sidebar" > <GanttSidebarHeader /> <div className="space-y-4">{children}</div> </div>);export type GanttAddFeatureHelperProps = { top: number; className?: string;};export const GanttAddFeatureHelper: FC<GanttAddFeatureHelperProps> = ({ top, className,}) => { const [scrollX] = useGanttScrollX(); const gantt = useContext(GanttContext); const [mousePosition, mouseRef] = useMouse<HTMLDivElement>(); const handleClick = () => { const ganttRect = gantt.ref?.current?.getBoundingClientRect(); const x = mousePosition.x - (ganttRect?.left ?? 0) + scrollX - gantt.sidebarWidth; const currentDate = getDateByMousePosition(gantt, x); gantt.onAddItem?.(currentDate); }; return ( <div className={cn('absolute top-0 w-full px-0.5', className)} ref={mouseRef} style={{ marginTop: -gantt.rowHeight / 2, transform: `translateY(${top}px)`, }} > <button className="flex h-full w-full items-center justify-center rounded-md border border-dashed p-2" onClick={handleClick} type="button" > <PlusIcon className="pointer-events-none select-none text-muted-foreground" size={16} /> </button> </div> );};export type GanttColumnProps = { index: number; isColumnSecondary?: (item: number) => boolean;};export const GanttColumn: FC<GanttColumnProps> = ({ index, isColumnSecondary,}) => { const gantt = useContext(GanttContext); const [dragging] = useGanttDragging(); const [mousePosition, mouseRef] = useMouse<HTMLDivElement>(); const [hovering, setHovering] = useState(false); const [windowScroll] = useWindowScroll(); const handleMouseEnter = () => setHovering(true); const handleMouseLeave = () => setHovering(false); const top = useThrottle( mousePosition.y - (mouseRef.current?.getBoundingClientRect().y ?? 0) - (windowScroll.y ?? 0), 10 ); return ( // biome-ignore lint/a11y/noStaticElementInteractions: "This is a clickable column" // biome-ignore lint/nursery/noNoninteractiveElementInteractions: "This is a clickable column" <div className={cn( 'group relative h-full overflow-hidden', isColumnSecondary?.(index) ? 'bg-secondary' : '' )} onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave} ref={mouseRef} > {!dragging && hovering && gantt.onAddItem ? ( <GanttAddFeatureHelper top={top} /> ) : null} </div> );};export type GanttColumnsProps = { columns: number; isColumnSecondary?: (item: number) => boolean;};export const GanttColumns: FC<GanttColumnsProps> = ({ columns, isColumnSecondary,}) => { const id = useId(); return ( <div className="divide grid h-full w-full divide-x divide-border/50" style={{ gridTemplateColumns: `repeat(${columns}, var(--gantt-column-width))`, }} > {Array.from({ length: columns }).map((_, index) => ( <GanttColumn index={index} isColumnSecondary={isColumnSecondary} key={`${id}-${index}`} /> ))} </div> );};export type GanttCreateMarkerTriggerProps = { onCreateMarker: (date: Date) => void; className?: string;};export const GanttCreateMarkerTrigger: FC<GanttCreateMarkerTriggerProps> = ({ onCreateMarker, className,}) => { const gantt = useContext(GanttContext); const [mousePosition, mouseRef] = useMouse<HTMLDivElement>(); const [windowScroll] = useWindowScroll(); const x = useThrottle( mousePosition.x - (mouseRef.current?.getBoundingClientRect().x ?? 0) - (windowScroll.x ?? 0), 10 ); const date = getDateByMousePosition(gantt, x); const handleClick = () => onCreateMarker(date); return ( <div className={cn( 'group pointer-events-none absolute top-0 left-0 h-full w-full select-none overflow-visible', className )} ref={mouseRef} > <div className="-ml-2 pointer-events-auto sticky top-6 z-20 flex w-4 flex-col items-center justify-center gap-1 overflow-visible opacity-0 group-hover:opacity-100" style={{ transform: `translateX(${x}px)` }} > <button className="z-50 inline-flex h-4 w-4 items-center justify-center rounded-full bg-card" onClick={handleClick} type="button" > <PlusIcon className="text-muted-foreground" size={12} /> </button> <div className="whitespace-nowrap rounded-full border border-border/50 bg-background/90 px-2 py-1 text-foreground text-xs backdrop-blur-lg"> {formatDate(date, 'MMM dd, yyyy')} </div> </div> </div> );};export type GanttFeatureDragHelperProps = { featureId: GanttFeature['id']; direction: 'left' | 'right'; date: Date | null;};export const GanttFeatureDragHelper: FC<GanttFeatureDragHelperProps> = ({ direction, featureId, date,}) => { const [, setDragging] = useGanttDragging(); const { attributes, listeners, setNodeRef } = useDraggable({ id: `feature-drag-helper-${featureId}`, }); const isPressed = Boolean(attributes['aria-pressed']); useEffect(() => setDragging(isPressed), [isPressed, setDragging]); return ( <div className={cn( 'group -translate-y-1/2 !cursor-col-resize absolute top-1/2 z-[3] h-full w-6 rounded-md outline-none', direction === 'left' ? '-left-2.5' : '-right-2.5' )} ref={setNodeRef} {...attributes} {...listeners} > <div className={cn( '-translate-y-1/2 absolute top-1/2 h-[80%] w-1 rounded-sm bg-muted-foreground opacity-0 transition-all', direction === 'left' ? 'left-2.5' : 'right-2.5', direction === 'left' ? 'group-hover:left-0' : 'group-hover:right-0', isPressed && (direction === 'left' ? 'left-0' : 'right-0'), 'group-hover:opacity-100', isPressed && 'opacity-100' )} /> {date && ( <div className={cn( '-translate-x-1/2 absolute top-10 hidden whitespace-nowrap rounded-lg border border-border/50 bg-background/90 px-2 py-1 text-foreground text-xs backdrop-blur-lg group-hover:block', isPressed && 'block' )} > {format(date, 'MMM dd, yyyy')} </div> )} </div> );};export type GanttFeatureItemCardProps = Pick<GanttFeature, 'id'> & { children?: ReactNode;};export const GanttFeatureItemCard: FC<GanttFeatureItemCardProps> = ({ id, children,}) => { const [, setDragging] = useGanttDragging(); const { attributes, listeners, setNodeRef } = useDraggable({ id }); const isPressed = Boolean(attributes['aria-pressed']); useEffect(() => setDragging(isPressed), [isPressed, setDragging]); return ( <Card className="h-full w-full rounded-md bg-background p-2 text-xs shadow-sm"> <div className={cn( 'flex h-full w-full items-center justify-between gap-2 text-left', isPressed && 'cursor-grabbing' )} {...attributes} {...listeners} ref={setNodeRef} > {children} </div> </Card> );};export type GanttFeatureItemProps = GanttFeature & { onMove?: (id: string, startDate: Date, endDate: Date | null) => void; children?: ReactNode; className?: string;};export const GanttFeatureItem: FC<GanttFeatureItemProps> = ({ onMove, children, className, ...feature}) => { const [scrollX] = useGanttScrollX(); const gantt = useContext(GanttContext); const timelineStartDate = useMemo( () => new Date(gantt.timelineData.at(0)?.year ?? 0, 0, 1), [gantt.timelineData] ); const [startAt, setStartAt] = useState<Date>(feature.startAt); const [endAt, setEndAt] = useState<Date | null>(feature.endAt); // Memoize expensive calculations const width = useMemo( () => getWidth(startAt, endAt, gantt), [startAt, endAt, gantt] ); const offset = useMemo( () => getOffset(startAt, timelineStartDate, gantt), [startAt, timelineStartDate, gantt] ); const addRange = useMemo(() => getAddRange(gantt.range), [gantt.range]); const [mousePosition] = useMouse<HTMLDivElement>(); const [previousMouseX, setPreviousMouseX] = useState(0); const [previousStartAt, setPreviousStartAt] = useState(startAt); const [previousEndAt, setPreviousEndAt] = useState(endAt); const mouseSensor = useSensor(MouseSensor, { activationConstraint: { distance: 10, }, }); const handleItemDragStart = useCallback(() => { setPreviousMouseX(mousePosition.x); setPreviousStartAt(startAt); setPreviousEndAt(endAt); }, [mousePosition.x, startAt, endAt]); const handleItemDragMove = useCallback(() => { const currentDate = getDateByMousePosition(gantt, mousePosition.x); const originalDate = getDateByMousePosition(gantt, previousMouseX); const delta = gantt.range === 'daily' ? getDifferenceIn(gantt.range)(currentDate, originalDate) : getInnerDifferenceIn(gantt.range)(currentDate, originalDate); const newStartDate = addDays(previousStartAt, delta); const newEndDate = previousEndAt ? addDays(previousEndAt, delta) : null; setStartAt(newStartDate); setEndAt(newEndDate); }, [gantt, mousePosition.x, previousMouseX, previousStartAt, previousEndAt]); const onDragEnd = useCallback( () => onMove?.(feature.id, startAt, endAt), [onMove, feature.id, startAt, endAt] ); const handleLeftDragMove = useCallback(() => { const ganttRect = gantt.ref?.current?.getBoundingClientRect(); const x = mousePosition.x - (ganttRect?.left ?? 0) + scrollX - gantt.sidebarWidth; const newStartAt = getDateByMousePosition(gantt, x); setStartAt(newStartAt); }, [gantt, mousePosition.x, scrollX]); const handleRightDragMove = useCallback(() => { const ganttRect = gantt.ref?.current?.getBoundingClientRect(); const x = mousePosition.x - (ganttRect?.left ?? 0) + scrollX - gantt.sidebarWidth; const newEndAt = getDateByMousePosition(gantt, x); setEndAt(newEndAt); }, [gantt, mousePosition.x, scrollX]); return ( <div className={cn('relative flex w-max min-w-full py-0.5', className)} style={{ height: 'var(--gantt-row-height)' }} > <div className="pointer-events-auto absolute top-0.5" style={{ height: 'calc(var(--gantt-row-height) - 4px)', width: Math.round(width), left: Math.round(offset), }} > {onMove && ( <DndContext modifiers={[restrictToHorizontalAxis]} onDragEnd={onDragEnd} onDragMove={handleLeftDragMove} sensors={[mouseSensor]} > <GanttFeatureDragHelper date={startAt} direction="left" featureId={feature.id} /> </DndContext> )} <DndContext modifiers={[restrictToHorizontalAxis]} onDragEnd={onDragEnd} onDragMove={handleItemDragMove} onDragStart={handleItemDragStart} sensors={[mouseSensor]} > <GanttFeatureItemCard id={feature.id}> {children ?? ( <p className="flex-1 truncate text-xs">{feature.name}</p> )} </GanttFeatureItemCard> </DndContext> {onMove && ( <DndContext modifiers={[restrictToHorizontalAxis]} onDragEnd={onDragEnd} onDragMove={handleRightDragMove} sensors={[mouseSensor]} > <GanttFeatureDragHelper date={endAt ?? addRange(startAt, 2)} direction="right" featureId={feature.id} /> </DndContext> )} </div> </div> );};export type GanttFeatureListGroupProps = { children: ReactNode; className?: string;};export const GanttFeatureListGroup: FC<GanttFeatureListGroupProps> = ({ children, className,}) => ( <div className={className} style={{ paddingTop: 'var(--gantt-row-height)' }}> {children} </div>);export type GanttFeatureRowProps = { features: GanttFeature[]; onMove?: (id: string, startAt: Date, endAt: Date | null) => void; children?: (feature: GanttFeature) => ReactNode; className?: string;};export const GanttFeatureRow: FC<GanttFeatureRowProps> = ({ features, onMove, children, className,}) => { // Sort features by start date to handle potential overlaps const sortedFeatures = [...features].sort((a, b) => a.startAt.getTime() - b.startAt.getTime() ); // Calculate sub-row positions for overlapping features using a proper algorithm const featureWithPositions = []; const subRowEndTimes: Date[] = []; // Track when each sub-row becomes free for (const feature of sortedFeatures) { let subRow = 0; // Find the first sub-row that's free (doesn't overlap) while (subRow < subRowEndTimes.length && subRowEndTimes[subRow] > feature.startAt) { subRow++; } // Update the end time for this sub-row if (subRow === subRowEndTimes.length) { subRowEndTimes.push(feature.endAt); } else { subRowEndTimes[subRow] = feature.endAt; } featureWithPositions.push({ ...feature, subRow }); } const maxSubRows = Math.max(1, subRowEndTimes.length); const subRowHeight = 36; // Base row height return ( <div className={cn('relative', className)} style={{ height: `${maxSubRows * subRowHeight}px`, minHeight: 'var(--gantt-row-height)' }} > {featureWithPositions.map((feature) => ( <div key={feature.id} className="absolute w-full" style={{ top: `${feature.subRow * subRowHeight}px`, height: `${subRowHeight}px` }} > <GanttFeatureItem {...feature} onMove={onMove} > {children ? children(feature) : ( <p className="flex-1 truncate text-xs">{feature.name}</p> )} </GanttFeatureItem> </div> ))} </div> );};export type GanttFeatureListProps = { className?: string; children: ReactNode;};export const GanttFeatureList: FC<GanttFeatureListProps> = ({ className, children,}) => ( <div className={cn('absolute top-0 left-0 h-full w-max space-y-4', className)} style={{ marginTop: 'var(--gantt-header-height)' }} > {children} </div>);export const GanttMarker: FC< GanttMarkerProps & { onRemove?: (id: string) => void; className?: string; }> = memo(({ label, date, id, onRemove, className }) => { const gantt = useContext(GanttContext); const differenceIn = useMemo( () => getDifferenceIn(gantt.range), [gantt.range] ); const timelineStartDate = useMemo( () => new Date(gantt.timelineData.at(0)?.year ?? 0, 0, 1), [gantt.timelineData] ); // Memoize expensive calculations const offset = useMemo( () => differenceIn(date, timelineStartDate), [differenceIn, date, timelineStartDate] ); const innerOffset = useMemo( () => calculateInnerOffset( date, gantt.range, (gantt.columnWidth * gantt.zoom) / 100 ), [date, gantt.range, gantt.columnWidth, gantt.zoom] ); const handleRemove = useCallback(() => onRemove?.(id), [onRemove, id]); return ( <div className="pointer-events-none absolute top-0 left-0 z-20 flex h-full select-none flex-col items-center justify-center overflow-visible" style={{ width: 0, transform: `translateX(calc(var(--gantt-column-width) * ${offset} + ${innerOffset}px))`, }} > <ContextMenu> <ContextMenuTrigger asChild> <div className={cn( 'group pointer-events-auto sticky top-0 flex select-auto flex-col flex-nowrap items-center justify-center whitespace-nowrap rounded-b-md bg-card px-2 py-1 text-foreground text-xs', className )} > {label} <span className="max-h-[0] overflow-hidden opacity-80 transition-all group-hover:max-h-[2rem]"> {formatDate(date, 'MMM dd, yyyy')} </span> </div> </ContextMenuTrigger> <ContextMenuContent> {onRemove ? ( <ContextMenuItem className="flex items-center gap-2 text-destructive" onClick={handleRemove} > <TrashIcon size={16} /> Remove marker </ContextMenuItem> ) : null} </ContextMenuContent> </ContextMenu> <div className={cn('h-full w-px bg-card', className)} /> </div> );});GanttMarker.displayName = 'GanttMarker';export type GanttProviderProps = { range?: Range; zoom?: number; onAddItem?: (date: Date) => void; children: ReactNode; className?: string;};export const GanttProvider: FC<GanttProviderProps> = ({ zoom = 100, range = 'monthly', onAddItem, children, className,}) => { const scrollRef = useRef<HTMLDivElement>(null); const [timelineData, setTimelineData] = useState<TimelineData>( createInitialTimelineData(new Date()) ); const [, setScrollX] = useGanttScrollX(); const [sidebarWidth, setSidebarWidth] = useState(0); const headerHeight = 60; const rowHeight = 36; let columnWidth = 50; if (range === 'monthly') { columnWidth = 150; } else if (range === 'quarterly') { columnWidth = 100; } // Memoize CSS variables to prevent unnecessary re-renders const cssVariables = useMemo( () => ({ '--gantt-zoom': `${zoom}`, '--gantt-column-width': `${(zoom / 100) * columnWidth}px`, '--gantt-header-height': `${headerHeight}px`, '--gantt-row-height': `${rowHeight}px`, '--gantt-sidebar-width': `${sidebarWidth}px`, }) as CSSProperties, [zoom, columnWidth, sidebarWidth] ); useEffect(() => { if (scrollRef.current) { scrollRef.current.scrollLeft = scrollRef.current.scrollWidth / 2 - scrollRef.current.clientWidth / 2; setScrollX(scrollRef.current.scrollLeft); } }, [setScrollX]); // Update sidebar width when DOM is ready useEffect(() => { const updateSidebarWidth = () => { const sidebarElement = scrollRef.current?.querySelector( '[data-roadmap-ui="gantt-sidebar"]' ); const newWidth = sidebarElement ? 300 : 0; setSidebarWidth(newWidth); }; // Update immediately updateSidebarWidth(); // Also update on resize or when children change const observer = new MutationObserver(updateSidebarWidth); if (scrollRef.current) { observer.observe(scrollRef.current, { childList: true, subtree: true, }); } return () => { observer.disconnect(); }; }, []); // Fix the useCallback to include all dependencies const handleScroll = useCallback( throttle(() => { const scrollElement = scrollRef.current; if (!scrollElement) { return; } const { scrollLeft, scrollWidth, clientWidth } = scrollElement; setScrollX(scrollLeft); if (scrollLeft === 0) { // Extend timelineData to the past const firstYear = timelineData[0]?.year; if (!firstYear) { return; } const newTimelineData: TimelineData = [...timelineData]; newTimelineData.unshift({ year: firstYear - 1, quarters: new Array(4).fill(null).map((_, quarterIndex) => ({ months: new Array(3).fill(null).map((_, monthIndex) => { const month = quarterIndex * 3 + monthIndex; return { days: getDaysInMonth(new Date(firstYear, month, 1)), }; }), })), }); setTimelineData(newTimelineData); // Scroll a bit forward so it's not at the very start scrollElement.scrollLeft = scrollElement.clientWidth; setScrollX(scrollElement.scrollLeft); } else if (scrollLeft + clientWidth >= scrollWidth) { // Extend timelineData to the future const lastYear = timelineData.at(-1)?.year; if (!lastYear) { return; } const newTimelineData: TimelineData = [...timelineData]; newTimelineData.push({ year: lastYear + 1, quarters: new Array(4).fill(null).map((_, quarterIndex) => ({ months: new Array(3).fill(null).map((_, monthIndex) => { const month = quarterIndex * 3 + monthIndex; return { days: getDaysInMonth(new Date(lastYear, month, 1)), }; }), })), }); setTimelineData(newTimelineData); // Scroll a bit back so it's not at the very end scrollElement.scrollLeft = scrollElement.scrollWidth - scrollElement.clientWidth; setScrollX(scrollElement.scrollLeft); } }, 100), [] ); useEffect(() => { const scrollElement = scrollRef.current; if (scrollElement) { scrollElement.addEventListener('scroll', handleScroll); } return () => { // Fix memory leak by properly referencing the scroll element if (scrollElement) { scrollElement.removeEventListener('scroll', handleScroll); } }; }, [handleScroll]); const scrollToFeature = useCallback((feature: GanttFeature) => { const scrollElement = scrollRef.current; if (!scrollElement) { return; } // Calculate timeline start date from timelineData const timelineStartDate = new Date(timelineData[0].year, 0, 1); // Calculate the horizontal offset for the feature's start date const offset = getOffset(feature.startAt, timelineStartDate, { zoom, range, columnWidth, sidebarWidth, headerHeight, rowHeight, onAddItem, placeholderLength: 2, timelineData, ref: scrollRef, }); // Scroll to align the feature's start with the right side of the sidebar const targetScrollLeft = Math.max(0, offset); scrollElement.scrollTo({ left: targetScrollLeft, behavior: 'smooth', }); }, [timelineData, zoom, range, columnWidth, sidebarWidth, headerHeight, rowHeight, onAddItem]); return ( <GanttContext.Provider value={{ zoom, range, headerHeight, columnWidth, sidebarWidth, rowHeight, onAddItem, timelineData, placeholderLength: 2, ref: scrollRef, scrollToFeature, }} > <div className={cn( 'gantt relative grid h-full w-full flex-none select-none overflow-auto rounded-sm bg-secondary', range, className )} ref={scrollRef} style={{ ...cssVariables, gridTemplateColumns: 'var(--gantt-sidebar-width) 1fr', }} > {children} </div> </GanttContext.Provider> );};export type GanttTimelineProps = { children: ReactNode; className?: string;};export const GanttTimeline: FC<GanttTimelineProps> = ({ children, className,}) => ( <div className={cn( 'relative flex h-full w-max flex-none overflow-clip', className )} > {children} </div>);export type GanttTodayProps = { className?: string;};export const GanttToday: FC<GanttTodayProps> = ({ className }) => { const label = 'Today'; const date = useMemo(() => new Date(), []); const gantt = useContext(GanttContext); const differenceIn = useMemo( () => getDifferenceIn(gantt.range), [gantt.range] ); const timelineStartDate = useMemo( () => new Date(gantt.timelineData.at(0)?.year ?? 0, 0, 1), [gantt.timelineData] ); // Memoize expensive calculations const offset = useMemo( () => differenceIn(date, timelineStartDate), [differenceIn, date, timelineStartDate] ); const innerOffset = useMemo( () => calculateInnerOffset( date, gantt.range, (gantt.columnWidth * gantt.zoom) / 100 ), [date, gantt.range, gantt.columnWidth, gantt.zoom] ); return ( <div className="pointer-events-none absolute top-0 left-0 z-20 flex h-full select-none flex-col items-center justify-center overflow-visible" style={{ width: 0, transform: `translateX(calc(var(--gantt-column-width) * ${offset} + ${innerOffset}px))`, }} > <div className={cn( 'group pointer-events-auto sticky top-0 flex select-auto flex-col flex-nowrap items-center justify-center whitespace-nowrap rounded-b-md bg-card px-2 py-1 text-foreground text-xs', className )} > {label} <span className="max-h-[0] overflow-hidden opacity-80 transition-all group-hover:max-h-[2rem]"> {formatDate(date, 'MMM dd, yyyy')} </span> </div> <div className={cn('h-full w-px bg-card', className)} /> </div> );};
Installation
npx shadcn@latest add https://www.shadcn.io/registry/gantt.json
npx shadcn@latest add https://www.shadcn.io/registry/gantt.json
pnpm dlx shadcn@latest add https://www.shadcn.io/registry/gantt.json
bunx shadcn@latest add https://www.shadcn.io/registry/gantt.json
Features
- Interactive timeline dragging for moving deadlines and extending task durations with intuitive mouse controls
- Multiple bookings per row supporting hotel reservations, equipment scheduling, and shared resource management
- Smart date intelligence with automatic today markers, custom milestones, and flexible date range handling using date-fns
- TypeScript native architecture built for reliability and optimized for Next.js application performance
- shadcn/ui design integration leveraging design tokens for consistent, professional appearance across React components
- Flexible layout options including sidebar visibility, read-only modes, and customizable groupings
- Enterprise-grade features with throttled interactions, efficient rendering, and complex project hierarchy support
- Open source freedom providing unlimited commercial use without licensing restrictions for JavaScript applications
Examples
Multiple items on one row
Perfect for hotel reservations, resource scheduling, or any scenario where multiple items share the same category but have different time periods.
'use client';import { faker } from '@faker-js/faker';import { GanttCreateMarkerTrigger, GanttFeatureList, GanttFeatureListGroup, GanttFeatureRow, GanttHeader, GanttMarker, GanttProvider, GanttSidebar, GanttSidebarGroup, GanttSidebarItem, GanttTimeline, GanttToday,} from '@/components/ui/shadcn-io/gantt';import groupBy from 'lodash.groupby';import { EyeIcon, LinkIcon, TrashIcon } from 'lucide-react';import { useState } from 'react';import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger,} from '@/components/ui/context-menu';const capitalize = (str: string) => str.charAt(0).toUpperCase() + str.slice(1);const statuses = [ { id: faker.string.uuid(), name: 'Confirmed', color: '#10B981' }, { id: faker.string.uuid(), name: 'Pending', color: '#F59E0B' }, { id: faker.string.uuid(), name: 'Cancelled', color: '#EF4444' },];const guests = Array.from({ length: 8 }) .fill(null) .map(() => ({ id: faker.string.uuid(), name: faker.person.fullName(), image: faker.image.avatar(), }));// Hotel roomsconst hotelRooms = Array.from({ length: 5 }) .fill(null) .map((_, index) => ({ id: faker.string.uuid(), name: `Room ${101 + index}`, }));// Generate hotel reservations - multiple guests can book the same room for different periodsconst hotelReservations = Array.from({ length: 12 }) .fill(null) .map(() => { const startDate = faker.date.future({ years: 0.3, refDate: new Date() }); const endDate = faker.date.future({ years: 0.1, refDate: startDate }); const room = faker.helpers.arrayElement(hotelRooms); const guest = faker.helpers.arrayElement(guests); return { id: faker.string.uuid(), name: `${guest.name} - ${faker.helpers.arrayElement(['Business Trip', 'Vacation', 'Conference', 'Weekend Getaway'])}`, startAt: startDate, endAt: endDate, status: faker.helpers.arrayElement(statuses), lane: room.id, // This groups reservations by room // Store additional data that's not part of core GanttFeature metadata: { guest, room, group: { name: 'Hotel Reservations' }, }, }; });const exampleMarkers = Array.from({ length: 3 }) .fill(null) .map(() => ({ id: faker.string.uuid(), date: faker.date.future({ years: 0.2, refDate: new Date() }), label: faker.helpers.arrayElement(['Holiday Period', 'Conference Week', 'Peak Season']), className: faker.helpers.arrayElement([ 'bg-blue-100 text-blue-900', 'bg-green-100 text-green-900', 'bg-purple-100 text-purple-900', ]), }));const Example = () => { const [reservations, setReservations] = useState(hotelReservations); // Group reservations by room (lane), then by group const groupedReservations = groupBy(reservations, 'metadata.group.name'); const roomGroupedReservations = Object.fromEntries( Object.entries(groupedReservations).map(([groupName, groupReservations]) => [ groupName, groupBy(groupReservations, 'lane'), ]) ); const handleViewReservation = (id: string) => console.log(`Reservation selected: ${id}`); const handleCopyLink = (id: string) => console.log(`Copy link: ${id}`); const handleRemoveReservation = (id: string) => setReservations((prev) => prev.filter((reservation) => reservation.id !== id)); const handleRemoveMarker = (id: string) => console.log(`Remove marker: ${id}`); const handleCreateMarker = (date: Date) => console.log(`Create marker: ${date.toISOString()}`); const handleMoveReservation = (id: string, startAt: Date, endAt: Date | null) => { if (!endAt) { return; } setReservations((prev) => prev.map((reservation) => reservation.id === id ? { ...reservation, startAt, endAt } : reservation ) ); console.log(`Move reservation: ${id} from ${startAt} to ${endAt}`); }; const handleAddReservation = (date: Date) => console.log(`Add reservation: ${date.toISOString()}`); return ( <GanttProvider className="border" onAddItem={handleAddReservation} range="monthly" zoom={100} > <GanttSidebar> {Object.entries(roomGroupedReservations).map(([groupName, roomReservations]) => ( <GanttSidebarGroup key={groupName} name={groupName}> {Object.entries(roomReservations).map(([roomId, roomReservationList]) => { const room = hotelRooms.find(r => r.id === roomId); // Create a representative feature for the sidebar const representativeReservation = { id: roomId, name: room?.name || 'Unknown Room', startAt: new Date(Math.min(...roomReservationList.map(r => r.startAt.getTime()))), endAt: new Date(Math.max(...roomReservationList.map(r => r.endAt.getTime()))), status: roomReservationList[0].status, }; return ( <GanttSidebarItem key={roomId} feature={representativeReservation} onSelectItem={() => handleViewReservation(roomId)} /> ); })} </GanttSidebarGroup> ))} </GanttSidebar> <GanttTimeline> <GanttHeader /> <GanttFeatureList> {Object.entries(roomGroupedReservations).map(([groupName, roomReservations]) => ( <GanttFeatureListGroup key={groupName}> {Object.entries(roomReservations).map(([roomId, roomReservationList]) => ( <div key={roomId}> <GanttFeatureRow features={roomReservationList} onMove={handleMoveReservation} > {(reservation) => ( <ContextMenu> <ContextMenuTrigger asChild> <div className="flex items-center gap-2 w-full"> <p className="flex-1 truncate text-xs"> {reservation.name} </p> {(reservation as any).metadata?.guest && ( <Avatar className="h-4 w-4"> <AvatarImage src={(reservation as any).metadata.guest.image} /> <AvatarFallback> {(reservation as any).metadata.guest.name?.slice(0, 2)} </AvatarFallback> </Avatar> )} </div> </ContextMenuTrigger> <ContextMenuContent> <ContextMenuItem className="flex items-center gap-2" onClick={() => handleViewReservation(reservation.id)} > <EyeIcon className="text-muted-foreground" size={16} /> View reservation </ContextMenuItem> <ContextMenuItem className="flex items-center gap-2" onClick={() => handleCopyLink(reservation.id)} > <LinkIcon className="text-muted-foreground" size={16} /> Copy link </ContextMenuItem> <ContextMenuItem className="flex items-center gap-2 text-destructive" onClick={() => handleRemoveReservation(reservation.id)} > <TrashIcon size={16} /> Cancel reservation </ContextMenuItem> </ContextMenuContent> </ContextMenu> )} </GanttFeatureRow> </div> ))} </GanttFeatureListGroup> ))} </GanttFeatureList> {exampleMarkers.map((marker) => ( <GanttMarker key={marker.id} {...marker} onRemove={handleRemoveMarker} /> ))} <GanttToday /> <GanttCreateMarkerTrigger onCreateMarker={handleCreateMarker} /> </GanttTimeline> </GanttProvider> );};export default Example;
'use client';import { DndContext, MouseSensor, useDraggable, useSensor,} from '@dnd-kit/core';import { restrictToHorizontalAxis } from '@dnd-kit/modifiers';import { useMouse, useThrottle, useWindowScroll } from '@uidotdev/usehooks';import { addDays, addMonths, differenceInDays, differenceInHours, differenceInMonths, endOfDay, endOfMonth, format, formatDate, formatDistance, getDate, getDaysInMonth, isSameDay, startOfDay, startOfMonth,} from 'date-fns';import { atom, useAtom } from 'jotai';import throttle from 'lodash.throttle';import { PlusIcon, TrashIcon } from 'lucide-react';import type { CSSProperties, FC, KeyboardEventHandler, MouseEventHandler, ReactNode, RefObject,} from 'react';import { createContext, memo, useCallback, useContext, useEffect, useId, useMemo, useRef, useState,} from 'react';import { Card } from '@/components/ui/card';import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger,} from '@/components/ui/context-menu';import { cn } from '@/lib/utils';const draggingAtom = atom(false);const scrollXAtom = atom(0);export const useGanttDragging = () => useAtom(draggingAtom);export const useGanttScrollX = () => useAtom(scrollXAtom);export type GanttStatus = { id: string; name: string; color: string;};export type GanttFeature = { id: string; name: string; startAt: Date; endAt: Date; status: GanttStatus; lane?: string; // Optional: features with the same lane will share a row};export type GanttMarkerProps = { id: string; date: Date; label: string;};export type Range = 'daily' | 'monthly' | 'quarterly';export type TimelineData = { year: number; quarters: { months: { days: number; }[]; }[];}[];export type GanttContextProps = { zoom: number; range: Range; columnWidth: number; sidebarWidth: number; headerHeight: number; rowHeight: number; onAddItem: ((date: Date) => void) | undefined; placeholderLength: number; timelineData: TimelineData; ref: RefObject<HTMLDivElement | null> | null; scrollToFeature?: (feature: GanttFeature) => void;};const getsDaysIn = (range: Range) => { // For when range is daily let fn = (_date: Date) => 1; if (range === 'monthly' || range === 'quarterly') { fn = getDaysInMonth; } return fn;};const getDifferenceIn = (range: Range) => { let fn = differenceInDays; if (range === 'monthly' || range === 'quarterly') { fn = differenceInMonths; } return fn;};const getInnerDifferenceIn = (range: Range) => { let fn = differenceInHours; if (range === 'monthly' || range === 'quarterly') { fn = differenceInDays; } return fn;};const getStartOf = (range: Range) => { let fn = startOfDay; if (range === 'monthly' || range === 'quarterly') { fn = startOfMonth; } return fn;};const getEndOf = (range: Range) => { let fn = endOfDay; if (range === 'monthly' || range === 'quarterly') { fn = endOfMonth; } return fn;};const getAddRange = (range: Range) => { let fn = addDays; if (range === 'monthly' || range === 'quarterly') { fn = addMonths; } return fn;};const getDateByMousePosition = (context: GanttContextProps, mouseX: number) => { const timelineStartDate = new Date(context.timelineData[0].year, 0, 1); const columnWidth = (context.columnWidth * context.zoom) / 100; const offset = Math.floor(mouseX / columnWidth); const daysIn = getsDaysIn(context.range); const addRange = getAddRange(context.range); const month = addRange(timelineStartDate, offset); const daysInMonth = daysIn(month); const pixelsPerDay = Math.round(columnWidth / daysInMonth); const dayOffset = Math.floor((mouseX % columnWidth) / pixelsPerDay); const actualDate = addDays(month, dayOffset); return actualDate;};const createInitialTimelineData = (today: Date) => { const data: TimelineData = []; data.push( { year: today.getFullYear() - 1, quarters: new Array(4).fill(null) }, { year: today.getFullYear(), quarters: new Array(4).fill(null) }, { year: today.getFullYear() + 1, quarters: new Array(4).fill(null) } ); for (const yearObj of data) { yearObj.quarters = new Array(4).fill(null).map((_, quarterIndex) => ({ months: new Array(3).fill(null).map((_, monthIndex) => { const month = quarterIndex * 3 + monthIndex; return { days: getDaysInMonth(new Date(yearObj.year, month, 1)), }; }), })); } return data;};const getOffset = ( date: Date, timelineStartDate: Date, context: GanttContextProps) => { const parsedColumnWidth = (context.columnWidth * context.zoom) / 100; const differenceIn = getDifferenceIn(context.range); const startOf = getStartOf(context.range); const fullColumns = differenceIn(startOf(date), timelineStartDate); if (context.range === 'daily') { return parsedColumnWidth * fullColumns; } const partialColumns = date.getDate(); const daysInMonth = getDaysInMonth(date); const pixelsPerDay = parsedColumnWidth / daysInMonth; return fullColumns * parsedColumnWidth + partialColumns * pixelsPerDay;};const getWidth = ( startAt: Date, endAt: Date | null, context: GanttContextProps) => { const parsedColumnWidth = (context.columnWidth * context.zoom) / 100; if (!endAt) { return parsedColumnWidth * 2; } const differenceIn = getDifferenceIn(context.range); if (context.range === 'daily') { const delta = differenceIn(endAt, startAt); return parsedColumnWidth * (delta ? delta : 1); } const daysInStartMonth = getDaysInMonth(startAt); const pixelsPerDayInStartMonth = parsedColumnWidth / daysInStartMonth; if (isSameDay(startAt, endAt)) { return pixelsPerDayInStartMonth; } const innerDifferenceIn = getInnerDifferenceIn(context.range); const startOf = getStartOf(context.range); if (isSameDay(startOf(startAt), startOf(endAt))) { return innerDifferenceIn(endAt, startAt) * pixelsPerDayInStartMonth; } const startRangeOffset = daysInStartMonth - getDate(startAt); const endRangeOffset = getDate(endAt); const fullRangeOffset = differenceIn(startOf(endAt), startOf(startAt)); const daysInEndMonth = getDaysInMonth(endAt); const pixelsPerDayInEndMonth = parsedColumnWidth / daysInEndMonth; return ( (fullRangeOffset - 1) * parsedColumnWidth + startRangeOffset * pixelsPerDayInStartMonth + endRangeOffset * pixelsPerDayInEndMonth );};const calculateInnerOffset = ( date: Date, range: Range, columnWidth: number) => { const startOf = getStartOf(range); const endOf = getEndOf(range); const differenceIn = getInnerDifferenceIn(range); const startOfRange = startOf(date); const endOfRange = endOf(date); const totalRangeDays = differenceIn(endOfRange, startOfRange); const dayOfMonth = date.getDate(); return (dayOfMonth / totalRangeDays) * columnWidth;};const GanttContext = createContext<GanttContextProps>({ zoom: 100, range: 'monthly', columnWidth: 50, headerHeight: 60, sidebarWidth: 300, rowHeight: 36, onAddItem: undefined, placeholderLength: 2, timelineData: [], ref: null, scrollToFeature: undefined,});export type GanttContentHeaderProps = { renderHeaderItem: (index: number) => ReactNode; title: string; columns: number;};export const GanttContentHeader: FC<GanttContentHeaderProps> = ({ title, columns, renderHeaderItem,}) => { const id = useId(); return ( <div className="sticky top-0 z-20 grid w-full shrink-0 bg-backdrop/90 backdrop-blur-sm" style={{ height: 'var(--gantt-header-height)' }} > <div> <div className="sticky inline-flex whitespace-nowrap px-3 py-2 text-muted-foreground text-xs" style={{ left: 'var(--gantt-sidebar-width)', }} > <p>{title}</p> </div> </div> <div className="grid w-full" style={{ gridTemplateColumns: `repeat(${columns}, var(--gantt-column-width))`, }} > {Array.from({ length: columns }).map((_, index) => ( <div className="shrink-0 border-border/50 border-b py-1 text-center text-xs" key={`${id}-${index}`} > {renderHeaderItem(index)} </div> ))} </div> </div> );};const DailyHeader: FC = () => { const gantt = useContext(GanttContext); return gantt.timelineData.map((year) => year.quarters .flatMap((quarter) => quarter.months) .map((month, index) => ( <div className="relative flex flex-col" key={`${year.year}-${index}`}> <GanttContentHeader columns={month.days} renderHeaderItem={(item: number) => ( <div className="flex items-center justify-center gap-1"> <p> {format(addDays(new Date(year.year, index, 1), item), 'd')} </p> <p className="text-muted-foreground"> {format( addDays(new Date(year.year, index, 1), item), 'EEEEE' )} </p> </div> )} title={format(new Date(year.year, index, 1), 'MMMM yyyy')} /> <GanttColumns columns={month.days} isColumnSecondary={(item: number) => [0, 6].includes( addDays(new Date(year.year, index, 1), item).getDay() ) } /> </div> )) );};const MonthlyHeader: FC = () => { const gantt = useContext(GanttContext); return gantt.timelineData.map((year) => ( <div className="relative flex flex-col" key={year.year}> <GanttContentHeader columns={year.quarters.flatMap((quarter) => quarter.months).length} renderHeaderItem={(item: number) => ( <p>{format(new Date(year.year, item, 1), 'MMM')}</p> )} title={`${year.year}`} /> <GanttColumns columns={year.quarters.flatMap((quarter) => quarter.months).length} /> </div> ));};const QuarterlyHeader: FC = () => { const gantt = useContext(GanttContext); return gantt.timelineData.map((year) => year.quarters.map((quarter, quarterIndex) => ( <div className="relative flex flex-col" key={`${year.year}-${quarterIndex}`} > <GanttContentHeader columns={quarter.months.length} renderHeaderItem={(item: number) => ( <p> {format(new Date(year.year, quarterIndex * 3 + item, 1), 'MMM')} </p> )} title={`Q${quarterIndex + 1} ${year.year}`} /> <GanttColumns columns={quarter.months.length} /> </div> )) );};const headers: Record<Range, FC> = { daily: DailyHeader, monthly: MonthlyHeader, quarterly: QuarterlyHeader,};export type GanttHeaderProps = { className?: string;};export const GanttHeader: FC<GanttHeaderProps> = ({ className }) => { const gantt = useContext(GanttContext); const Header = headers[gantt.range]; return ( <div className={cn( '-space-x-px flex h-full w-max divide-x divide-border/50', className )} > <Header /> </div> );};export type GanttSidebarItemProps = { feature: GanttFeature; onSelectItem?: (id: string) => void; className?: string;};export const GanttSidebarItem: FC<GanttSidebarItemProps> = ({ feature, onSelectItem, className,}) => { const gantt = useContext(GanttContext); const tempEndAt = feature.endAt && isSameDay(feature.startAt, feature.endAt) ? addDays(feature.endAt, 1) : feature.endAt; const duration = tempEndAt ? formatDistance(feature.startAt, tempEndAt) : `${formatDistance(feature.startAt, new Date())} so far`; const handleClick: MouseEventHandler<HTMLDivElement> = (event) => { if (event.target === event.currentTarget) { // Scroll to the feature in the timeline gantt.scrollToFeature?.(feature); // Call the original onSelectItem callback onSelectItem?.(feature.id); } }; const handleKeyDown: KeyboardEventHandler<HTMLDivElement> = (event) => { if (event.key === 'Enter') { // Scroll to the feature in the timeline gantt.scrollToFeature?.(feature); // Call the original onSelectItem callback onSelectItem?.(feature.id); } }; return ( <div className={cn( 'relative flex items-center gap-2.5 p-2.5 text-xs hover:bg-secondary', className )} key={feature.id} onClick={handleClick} onKeyDown={handleKeyDown} // biome-ignore lint/a11y/useSemanticElements: "This is a clickable item" role="button" style={{ height: 'var(--gantt-row-height)', }} tabIndex={0} > {/* <Checkbox onCheckedChange={handleCheck} className="shrink-0" /> */} <div className="pointer-events-none h-2 w-2 shrink-0 rounded-full" style={{ backgroundColor: feature.status.color, }} /> <p className="pointer-events-none flex-1 truncate text-left font-medium"> {feature.name} </p> <p className="pointer-events-none text-muted-foreground">{duration}</p> </div> );};export const GanttSidebarHeader: FC = () => ( <div className="sticky top-0 z-10 flex shrink-0 items-end justify-between gap-2.5 border-border/50 border-b bg-backdrop/90 p-2.5 font-medium text-muted-foreground text-xs backdrop-blur-sm" style={{ height: 'var(--gantt-header-height)' }} > {/* <Checkbox className="shrink-0" /> */} <p className="flex-1 truncate text-left">Issues</p> <p className="shrink-0">Duration</p> </div>);export type GanttSidebarGroupProps = { children: ReactNode; name: string; className?: string;};export const GanttSidebarGroup: FC<GanttSidebarGroupProps> = ({ children, name, className,}) => ( <div className={className}> <p className="w-full truncate p-2.5 text-left font-medium text-muted-foreground text-xs" style={{ height: 'var(--gantt-row-height)' }} > {name} </p> <div className="divide-y divide-border/50">{children}</div> </div>);export type GanttSidebarProps = { children: ReactNode; className?: string;};export const GanttSidebar: FC<GanttSidebarProps> = ({ children, className,}) => ( <div className={cn( 'sticky left-0 z-30 h-max min-h-full overflow-clip border-border/50 border-r bg-background/90 backdrop-blur-md', className )} data-roadmap-ui="gantt-sidebar" > <GanttSidebarHeader /> <div className="space-y-4">{children}</div> </div>);export type GanttAddFeatureHelperProps = { top: number; className?: string;};export const GanttAddFeatureHelper: FC<GanttAddFeatureHelperProps> = ({ top, className,}) => { const [scrollX] = useGanttScrollX(); const gantt = useContext(GanttContext); const [mousePosition, mouseRef] = useMouse<HTMLDivElement>(); const handleClick = () => { const ganttRect = gantt.ref?.current?.getBoundingClientRect(); const x = mousePosition.x - (ganttRect?.left ?? 0) + scrollX - gantt.sidebarWidth; const currentDate = getDateByMousePosition(gantt, x); gantt.onAddItem?.(currentDate); }; return ( <div className={cn('absolute top-0 w-full px-0.5', className)} ref={mouseRef} style={{ marginTop: -gantt.rowHeight / 2, transform: `translateY(${top}px)`, }} > <button className="flex h-full w-full items-center justify-center rounded-md border border-dashed p-2" onClick={handleClick} type="button" > <PlusIcon className="pointer-events-none select-none text-muted-foreground" size={16} /> </button> </div> );};export type GanttColumnProps = { index: number; isColumnSecondary?: (item: number) => boolean;};export const GanttColumn: FC<GanttColumnProps> = ({ index, isColumnSecondary,}) => { const gantt = useContext(GanttContext); const [dragging] = useGanttDragging(); const [mousePosition, mouseRef] = useMouse<HTMLDivElement>(); const [hovering, setHovering] = useState(false); const [windowScroll] = useWindowScroll(); const handleMouseEnter = () => setHovering(true); const handleMouseLeave = () => setHovering(false); const top = useThrottle( mousePosition.y - (mouseRef.current?.getBoundingClientRect().y ?? 0) - (windowScroll.y ?? 0), 10 ); return ( // biome-ignore lint/a11y/noStaticElementInteractions: "This is a clickable column" // biome-ignore lint/nursery/noNoninteractiveElementInteractions: "This is a clickable column" <div className={cn( 'group relative h-full overflow-hidden', isColumnSecondary?.(index) ? 'bg-secondary' : '' )} onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave} ref={mouseRef} > {!dragging && hovering && gantt.onAddItem ? ( <GanttAddFeatureHelper top={top} /> ) : null} </div> );};export type GanttColumnsProps = { columns: number; isColumnSecondary?: (item: number) => boolean;};export const GanttColumns: FC<GanttColumnsProps> = ({ columns, isColumnSecondary,}) => { const id = useId(); return ( <div className="divide grid h-full w-full divide-x divide-border/50" style={{ gridTemplateColumns: `repeat(${columns}, var(--gantt-column-width))`, }} > {Array.from({ length: columns }).map((_, index) => ( <GanttColumn index={index} isColumnSecondary={isColumnSecondary} key={`${id}-${index}`} /> ))} </div> );};export type GanttCreateMarkerTriggerProps = { onCreateMarker: (date: Date) => void; className?: string;};export const GanttCreateMarkerTrigger: FC<GanttCreateMarkerTriggerProps> = ({ onCreateMarker, className,}) => { const gantt = useContext(GanttContext); const [mousePosition, mouseRef] = useMouse<HTMLDivElement>(); const [windowScroll] = useWindowScroll(); const x = useThrottle( mousePosition.x - (mouseRef.current?.getBoundingClientRect().x ?? 0) - (windowScroll.x ?? 0), 10 ); const date = getDateByMousePosition(gantt, x); const handleClick = () => onCreateMarker(date); return ( <div className={cn( 'group pointer-events-none absolute top-0 left-0 h-full w-full select-none overflow-visible', className )} ref={mouseRef} > <div className="-ml-2 pointer-events-auto sticky top-6 z-20 flex w-4 flex-col items-center justify-center gap-1 overflow-visible opacity-0 group-hover:opacity-100" style={{ transform: `translateX(${x}px)` }} > <button className="z-50 inline-flex h-4 w-4 items-center justify-center rounded-full bg-card" onClick={handleClick} type="button" > <PlusIcon className="text-muted-foreground" size={12} /> </button> <div className="whitespace-nowrap rounded-full border border-border/50 bg-background/90 px-2 py-1 text-foreground text-xs backdrop-blur-lg"> {formatDate(date, 'MMM dd, yyyy')} </div> </div> </div> );};export type GanttFeatureDragHelperProps = { featureId: GanttFeature['id']; direction: 'left' | 'right'; date: Date | null;};export const GanttFeatureDragHelper: FC<GanttFeatureDragHelperProps> = ({ direction, featureId, date,}) => { const [, setDragging] = useGanttDragging(); const { attributes, listeners, setNodeRef } = useDraggable({ id: `feature-drag-helper-${featureId}`, }); const isPressed = Boolean(attributes['aria-pressed']); useEffect(() => setDragging(isPressed), [isPressed, setDragging]); return ( <div className={cn( 'group -translate-y-1/2 !cursor-col-resize absolute top-1/2 z-[3] h-full w-6 rounded-md outline-none', direction === 'left' ? '-left-2.5' : '-right-2.5' )} ref={setNodeRef} {...attributes} {...listeners} > <div className={cn( '-translate-y-1/2 absolute top-1/2 h-[80%] w-1 rounded-sm bg-muted-foreground opacity-0 transition-all', direction === 'left' ? 'left-2.5' : 'right-2.5', direction === 'left' ? 'group-hover:left-0' : 'group-hover:right-0', isPressed && (direction === 'left' ? 'left-0' : 'right-0'), 'group-hover:opacity-100', isPressed && 'opacity-100' )} /> {date && ( <div className={cn( '-translate-x-1/2 absolute top-10 hidden whitespace-nowrap rounded-lg border border-border/50 bg-background/90 px-2 py-1 text-foreground text-xs backdrop-blur-lg group-hover:block', isPressed && 'block' )} > {format(date, 'MMM dd, yyyy')} </div> )} </div> );};export type GanttFeatureItemCardProps = Pick<GanttFeature, 'id'> & { children?: ReactNode;};export const GanttFeatureItemCard: FC<GanttFeatureItemCardProps> = ({ id, children,}) => { const [, setDragging] = useGanttDragging(); const { attributes, listeners, setNodeRef } = useDraggable({ id }); const isPressed = Boolean(attributes['aria-pressed']); useEffect(() => setDragging(isPressed), [isPressed, setDragging]); return ( <Card className="h-full w-full rounded-md bg-background p-2 text-xs shadow-sm"> <div className={cn( 'flex h-full w-full items-center justify-between gap-2 text-left', isPressed && 'cursor-grabbing' )} {...attributes} {...listeners} ref={setNodeRef} > {children} </div> </Card> );};export type GanttFeatureItemProps = GanttFeature & { onMove?: (id: string, startDate: Date, endDate: Date | null) => void; children?: ReactNode; className?: string;};export const GanttFeatureItem: FC<GanttFeatureItemProps> = ({ onMove, children, className, ...feature}) => { const [scrollX] = useGanttScrollX(); const gantt = useContext(GanttContext); const timelineStartDate = useMemo( () => new Date(gantt.timelineData.at(0)?.year ?? 0, 0, 1), [gantt.timelineData] ); const [startAt, setStartAt] = useState<Date>(feature.startAt); const [endAt, setEndAt] = useState<Date | null>(feature.endAt); // Memoize expensive calculations const width = useMemo( () => getWidth(startAt, endAt, gantt), [startAt, endAt, gantt] ); const offset = useMemo( () => getOffset(startAt, timelineStartDate, gantt), [startAt, timelineStartDate, gantt] ); const addRange = useMemo(() => getAddRange(gantt.range), [gantt.range]); const [mousePosition] = useMouse<HTMLDivElement>(); const [previousMouseX, setPreviousMouseX] = useState(0); const [previousStartAt, setPreviousStartAt] = useState(startAt); const [previousEndAt, setPreviousEndAt] = useState(endAt); const mouseSensor = useSensor(MouseSensor, { activationConstraint: { distance: 10, }, }); const handleItemDragStart = useCallback(() => { setPreviousMouseX(mousePosition.x); setPreviousStartAt(startAt); setPreviousEndAt(endAt); }, [mousePosition.x, startAt, endAt]); const handleItemDragMove = useCallback(() => { const currentDate = getDateByMousePosition(gantt, mousePosition.x); const originalDate = getDateByMousePosition(gantt, previousMouseX); const delta = gantt.range === 'daily' ? getDifferenceIn(gantt.range)(currentDate, originalDate) : getInnerDifferenceIn(gantt.range)(currentDate, originalDate); const newStartDate = addDays(previousStartAt, delta); const newEndDate = previousEndAt ? addDays(previousEndAt, delta) : null; setStartAt(newStartDate); setEndAt(newEndDate); }, [gantt, mousePosition.x, previousMouseX, previousStartAt, previousEndAt]); const onDragEnd = useCallback( () => onMove?.(feature.id, startAt, endAt), [onMove, feature.id, startAt, endAt] ); const handleLeftDragMove = useCallback(() => { const ganttRect = gantt.ref?.current?.getBoundingClientRect(); const x = mousePosition.x - (ganttRect?.left ?? 0) + scrollX - gantt.sidebarWidth; const newStartAt = getDateByMousePosition(gantt, x); setStartAt(newStartAt); }, [gantt, mousePosition.x, scrollX]); const handleRightDragMove = useCallback(() => { const ganttRect = gantt.ref?.current?.getBoundingClientRect(); const x = mousePosition.x - (ganttRect?.left ?? 0) + scrollX - gantt.sidebarWidth; const newEndAt = getDateByMousePosition(gantt, x); setEndAt(newEndAt); }, [gantt, mousePosition.x, scrollX]); return ( <div className={cn('relative flex w-max min-w-full py-0.5', className)} style={{ height: 'var(--gantt-row-height)' }} > <div className="pointer-events-auto absolute top-0.5" style={{ height: 'calc(var(--gantt-row-height) - 4px)', width: Math.round(width), left: Math.round(offset), }} > {onMove && ( <DndContext modifiers={[restrictToHorizontalAxis]} onDragEnd={onDragEnd} onDragMove={handleLeftDragMove} sensors={[mouseSensor]} > <GanttFeatureDragHelper date={startAt} direction="left" featureId={feature.id} /> </DndContext> )} <DndContext modifiers={[restrictToHorizontalAxis]} onDragEnd={onDragEnd} onDragMove={handleItemDragMove} onDragStart={handleItemDragStart} sensors={[mouseSensor]} > <GanttFeatureItemCard id={feature.id}> {children ?? ( <p className="flex-1 truncate text-xs">{feature.name}</p> )} </GanttFeatureItemCard> </DndContext> {onMove && ( <DndContext modifiers={[restrictToHorizontalAxis]} onDragEnd={onDragEnd} onDragMove={handleRightDragMove} sensors={[mouseSensor]} > <GanttFeatureDragHelper date={endAt ?? addRange(startAt, 2)} direction="right" featureId={feature.id} /> </DndContext> )} </div> </div> );};export type GanttFeatureListGroupProps = { children: ReactNode; className?: string;};export const GanttFeatureListGroup: FC<GanttFeatureListGroupProps> = ({ children, className,}) => ( <div className={className} style={{ paddingTop: 'var(--gantt-row-height)' }}> {children} </div>);export type GanttFeatureRowProps = { features: GanttFeature[]; onMove?: (id: string, startAt: Date, endAt: Date | null) => void; children?: (feature: GanttFeature) => ReactNode; className?: string;};export const GanttFeatureRow: FC<GanttFeatureRowProps> = ({ features, onMove, children, className,}) => { // Sort features by start date to handle potential overlaps const sortedFeatures = [...features].sort((a, b) => a.startAt.getTime() - b.startAt.getTime() ); // Calculate sub-row positions for overlapping features using a proper algorithm const featureWithPositions = []; const subRowEndTimes: Date[] = []; // Track when each sub-row becomes free for (const feature of sortedFeatures) { let subRow = 0; // Find the first sub-row that's free (doesn't overlap) while (subRow < subRowEndTimes.length && subRowEndTimes[subRow] > feature.startAt) { subRow++; } // Update the end time for this sub-row if (subRow === subRowEndTimes.length) { subRowEndTimes.push(feature.endAt); } else { subRowEndTimes[subRow] = feature.endAt; } featureWithPositions.push({ ...feature, subRow }); } const maxSubRows = Math.max(1, subRowEndTimes.length); const subRowHeight = 36; // Base row height return ( <div className={cn('relative', className)} style={{ height: `${maxSubRows * subRowHeight}px`, minHeight: 'var(--gantt-row-height)' }} > {featureWithPositions.map((feature) => ( <div key={feature.id} className="absolute w-full" style={{ top: `${feature.subRow * subRowHeight}px`, height: `${subRowHeight}px` }} > <GanttFeatureItem {...feature} onMove={onMove} > {children ? children(feature) : ( <p className="flex-1 truncate text-xs">{feature.name}</p> )} </GanttFeatureItem> </div> ))} </div> );};export type GanttFeatureListProps = { className?: string; children: ReactNode;};export const GanttFeatureList: FC<GanttFeatureListProps> = ({ className, children,}) => ( <div className={cn('absolute top-0 left-0 h-full w-max space-y-4', className)} style={{ marginTop: 'var(--gantt-header-height)' }} > {children} </div>);export const GanttMarker: FC< GanttMarkerProps & { onRemove?: (id: string) => void; className?: string; }> = memo(({ label, date, id, onRemove, className }) => { const gantt = useContext(GanttContext); const differenceIn = useMemo( () => getDifferenceIn(gantt.range), [gantt.range] ); const timelineStartDate = useMemo( () => new Date(gantt.timelineData.at(0)?.year ?? 0, 0, 1), [gantt.timelineData] ); // Memoize expensive calculations const offset = useMemo( () => differenceIn(date, timelineStartDate), [differenceIn, date, timelineStartDate] ); const innerOffset = useMemo( () => calculateInnerOffset( date, gantt.range, (gantt.columnWidth * gantt.zoom) / 100 ), [date, gantt.range, gantt.columnWidth, gantt.zoom] ); const handleRemove = useCallback(() => onRemove?.(id), [onRemove, id]); return ( <div className="pointer-events-none absolute top-0 left-0 z-20 flex h-full select-none flex-col items-center justify-center overflow-visible" style={{ width: 0, transform: `translateX(calc(var(--gantt-column-width) * ${offset} + ${innerOffset}px))`, }} > <ContextMenu> <ContextMenuTrigger asChild> <div className={cn( 'group pointer-events-auto sticky top-0 flex select-auto flex-col flex-nowrap items-center justify-center whitespace-nowrap rounded-b-md bg-card px-2 py-1 text-foreground text-xs', className )} > {label} <span className="max-h-[0] overflow-hidden opacity-80 transition-all group-hover:max-h-[2rem]"> {formatDate(date, 'MMM dd, yyyy')} </span> </div> </ContextMenuTrigger> <ContextMenuContent> {onRemove ? ( <ContextMenuItem className="flex items-center gap-2 text-destructive" onClick={handleRemove} > <TrashIcon size={16} /> Remove marker </ContextMenuItem> ) : null} </ContextMenuContent> </ContextMenu> <div className={cn('h-full w-px bg-card', className)} /> </div> );});GanttMarker.displayName = 'GanttMarker';export type GanttProviderProps = { range?: Range; zoom?: number; onAddItem?: (date: Date) => void; children: ReactNode; className?: string;};export const GanttProvider: FC<GanttProviderProps> = ({ zoom = 100, range = 'monthly', onAddItem, children, className,}) => { const scrollRef = useRef<HTMLDivElement>(null); const [timelineData, setTimelineData] = useState<TimelineData>( createInitialTimelineData(new Date()) ); const [, setScrollX] = useGanttScrollX(); const [sidebarWidth, setSidebarWidth] = useState(0); const headerHeight = 60; const rowHeight = 36; let columnWidth = 50; if (range === 'monthly') { columnWidth = 150; } else if (range === 'quarterly') { columnWidth = 100; } // Memoize CSS variables to prevent unnecessary re-renders const cssVariables = useMemo( () => ({ '--gantt-zoom': `${zoom}`, '--gantt-column-width': `${(zoom / 100) * columnWidth}px`, '--gantt-header-height': `${headerHeight}px`, '--gantt-row-height': `${rowHeight}px`, '--gantt-sidebar-width': `${sidebarWidth}px`, }) as CSSProperties, [zoom, columnWidth, sidebarWidth] ); useEffect(() => { if (scrollRef.current) { scrollRef.current.scrollLeft = scrollRef.current.scrollWidth / 2 - scrollRef.current.clientWidth / 2; setScrollX(scrollRef.current.scrollLeft); } }, [setScrollX]); // Update sidebar width when DOM is ready useEffect(() => { const updateSidebarWidth = () => { const sidebarElement = scrollRef.current?.querySelector( '[data-roadmap-ui="gantt-sidebar"]' ); const newWidth = sidebarElement ? 300 : 0; setSidebarWidth(newWidth); }; // Update immediately updateSidebarWidth(); // Also update on resize or when children change const observer = new MutationObserver(updateSidebarWidth); if (scrollRef.current) { observer.observe(scrollRef.current, { childList: true, subtree: true, }); } return () => { observer.disconnect(); }; }, []); // Fix the useCallback to include all dependencies const handleScroll = useCallback( throttle(() => { const scrollElement = scrollRef.current; if (!scrollElement) { return; } const { scrollLeft, scrollWidth, clientWidth } = scrollElement; setScrollX(scrollLeft); if (scrollLeft === 0) { // Extend timelineData to the past const firstYear = timelineData[0]?.year; if (!firstYear) { return; } const newTimelineData: TimelineData = [...timelineData]; newTimelineData.unshift({ year: firstYear - 1, quarters: new Array(4).fill(null).map((_, quarterIndex) => ({ months: new Array(3).fill(null).map((_, monthIndex) => { const month = quarterIndex * 3 + monthIndex; return { days: getDaysInMonth(new Date(firstYear, month, 1)), }; }), })), }); setTimelineData(newTimelineData); // Scroll a bit forward so it's not at the very start scrollElement.scrollLeft = scrollElement.clientWidth; setScrollX(scrollElement.scrollLeft); } else if (scrollLeft + clientWidth >= scrollWidth) { // Extend timelineData to the future const lastYear = timelineData.at(-1)?.year; if (!lastYear) { return; } const newTimelineData: TimelineData = [...timelineData]; newTimelineData.push({ year: lastYear + 1, quarters: new Array(4).fill(null).map((_, quarterIndex) => ({ months: new Array(3).fill(null).map((_, monthIndex) => { const month = quarterIndex * 3 + monthIndex; return { days: getDaysInMonth(new Date(lastYear, month, 1)), }; }), })), }); setTimelineData(newTimelineData); // Scroll a bit back so it's not at the very end scrollElement.scrollLeft = scrollElement.scrollWidth - scrollElement.clientWidth; setScrollX(scrollElement.scrollLeft); } }, 100), [] ); useEffect(() => { const scrollElement = scrollRef.current; if (scrollElement) { scrollElement.addEventListener('scroll', handleScroll); } return () => { // Fix memory leak by properly referencing the scroll element if (scrollElement) { scrollElement.removeEventListener('scroll', handleScroll); } }; }, [handleScroll]); const scrollToFeature = useCallback((feature: GanttFeature) => { const scrollElement = scrollRef.current; if (!scrollElement) { return; } // Calculate timeline start date from timelineData const timelineStartDate = new Date(timelineData[0].year, 0, 1); // Calculate the horizontal offset for the feature's start date const offset = getOffset(feature.startAt, timelineStartDate, { zoom, range, columnWidth, sidebarWidth, headerHeight, rowHeight, onAddItem, placeholderLength: 2, timelineData, ref: scrollRef, }); // Scroll to align the feature's start with the right side of the sidebar const targetScrollLeft = Math.max(0, offset); scrollElement.scrollTo({ left: targetScrollLeft, behavior: 'smooth', }); }, [timelineData, zoom, range, columnWidth, sidebarWidth, headerHeight, rowHeight, onAddItem]); return ( <GanttContext.Provider value={{ zoom, range, headerHeight, columnWidth, sidebarWidth, rowHeight, onAddItem, timelineData, placeholderLength: 2, ref: scrollRef, scrollToFeature, }} > <div className={cn( 'gantt relative grid h-full w-full flex-none select-none overflow-auto rounded-sm bg-secondary', range, className )} ref={scrollRef} style={{ ...cssVariables, gridTemplateColumns: 'var(--gantt-sidebar-width) 1fr', }} > {children} </div> </GanttContext.Provider> );};export type GanttTimelineProps = { children: ReactNode; className?: string;};export const GanttTimeline: FC<GanttTimelineProps> = ({ children, className,}) => ( <div className={cn( 'relative flex h-full w-max flex-none overflow-clip', className )} > {children} </div>);export type GanttTodayProps = { className?: string;};export const GanttToday: FC<GanttTodayProps> = ({ className }) => { const label = 'Today'; const date = useMemo(() => new Date(), []); const gantt = useContext(GanttContext); const differenceIn = useMemo( () => getDifferenceIn(gantt.range), [gantt.range] ); const timelineStartDate = useMemo( () => new Date(gantt.timelineData.at(0)?.year ?? 0, 0, 1), [gantt.timelineData] ); // Memoize expensive calculations const offset = useMemo( () => differenceIn(date, timelineStartDate), [differenceIn, date, timelineStartDate] ); const innerOffset = useMemo( () => calculateInnerOffset( date, gantt.range, (gantt.columnWidth * gantt.zoom) / 100 ), [date, gantt.range, gantt.columnWidth, gantt.zoom] ); return ( <div className="pointer-events-none absolute top-0 left-0 z-20 flex h-full select-none flex-col items-center justify-center overflow-visible" style={{ width: 0, transform: `translateX(calc(var(--gantt-column-width) * ${offset} + ${innerOffset}px))`, }} > <div className={cn( 'group pointer-events-auto sticky top-0 flex select-auto flex-col flex-nowrap items-center justify-center whitespace-nowrap rounded-b-md bg-card px-2 py-1 text-foreground text-xs', className )} > {label} <span className="max-h-[0] overflow-hidden opacity-80 transition-all group-hover:max-h-[2rem]"> {formatDate(date, 'MMM dd, yyyy')} </span> </div> <div className={cn('h-full w-px bg-card', className)} /> </div> );};
Without a sidebar
'use client';import { faker } from '@faker-js/faker';import { GanttCreateMarkerTrigger, GanttFeatureItem, GanttFeatureList, GanttFeatureListGroup, GanttHeader, GanttMarker, GanttProvider, GanttTimeline, GanttToday,} from '@/components/ui/shadcn-io/gantt';import groupBy from 'lodash.groupby';import { EyeIcon, LinkIcon, TrashIcon } from 'lucide-react';import { useState } from 'react';import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger,} from '@/components/ui/context-menu';const capitalize = (str: string) => str.charAt(0).toUpperCase() + str.slice(1);const statuses = [ { id: faker.string.uuid(), name: 'Planned', color: '#6B7280' }, { id: faker.string.uuid(), name: 'In Progress', color: '#F59E0B' }, { id: faker.string.uuid(), name: 'Done', color: '#10B981' },];const users = Array.from({ length: 4 }) .fill(null) .map(() => ({ id: faker.string.uuid(), name: faker.person.fullName(), image: faker.image.avatar(), }));const exampleGroups = Array.from({ length: 6 }) .fill(null) .map(() => ({ id: faker.string.uuid(), name: capitalize(faker.company.buzzPhrase()), }));const exampleProducts = Array.from({ length: 4 }) .fill(null) .map(() => ({ id: faker.string.uuid(), name: capitalize(faker.company.buzzPhrase()), }));const exampleInitiatives = Array.from({ length: 2 }) .fill(null) .map(() => ({ id: faker.string.uuid(), name: capitalize(faker.company.buzzPhrase()), }));const exampleReleases = Array.from({ length: 3 }) .fill(null) .map(() => ({ id: faker.string.uuid(), name: capitalize(faker.company.buzzPhrase()), }));const exampleFeatures = Array.from({ length: 20 }) .fill(null) .map(() => ({ id: faker.string.uuid(), name: capitalize(faker.company.buzzPhrase()), startAt: faker.date.past({ years: 0.5, refDate: new Date() }), endAt: faker.date.future({ years: 0.5, refDate: new Date() }), status: faker.helpers.arrayElement(statuses), owner: faker.helpers.arrayElement(users), group: faker.helpers.arrayElement(exampleGroups), product: faker.helpers.arrayElement(exampleProducts), initiative: faker.helpers.arrayElement(exampleInitiatives), release: faker.helpers.arrayElement(exampleReleases), }));const exampleMarkers = Array.from({ length: 6 }) .fill(null) .map(() => ({ id: faker.string.uuid(), date: faker.date.past({ years: 0.5, refDate: new Date() }), label: capitalize(faker.company.buzzPhrase()), className: faker.helpers.arrayElement([ 'bg-blue-100 text-blue-900', 'bg-green-100 text-green-900', 'bg-purple-100 text-purple-900', 'bg-red-100 text-red-900', 'bg-orange-100 text-orange-900', 'bg-teal-100 text-teal-900', ]), }));const Example = () => { const [features, setFeatures] = useState(exampleFeatures); const groupedFeatures = groupBy(features, 'group.name'); const sortedGroupedFeatures = Object.fromEntries( Object.entries(groupedFeatures).sort(([nameA], [nameB]) => nameA.localeCompare(nameB) ) ); const handleViewFeature = (id: string) => console.log(`Feature selected: ${id}`); const handleCopyLink = (id: string) => console.log(`Copy link: ${id}`); const handleRemoveFeature = (id: string) => setFeatures((prev) => prev.filter((feature) => feature.id !== id)); const handleRemoveMarker = (id: string) => console.log(`Remove marker: ${id}`); const handleCreateMarker = (date: Date) => console.log(`Create marker: ${date.toISOString()}`); const handleMoveFeature = (id: string, startAt: Date, endAt: Date | null) => { if (!endAt) { return; } setFeatures((prev) => prev.map((feature) => feature.id === id ? { ...feature, startAt, endAt } : feature ) ); console.log(`Move feature: ${id} from ${startAt} to ${endAt}`); }; const handleAddFeature = (date: Date) => console.log(`Add feature: ${date.toISOString()}`); return ( <GanttProvider className="border" onAddItem={handleAddFeature} range="monthly" zoom={100} > <GanttTimeline> <GanttHeader /> <GanttFeatureList> {Object.entries(sortedGroupedFeatures).map(([group, features]) => ( <GanttFeatureListGroup key={group}> {features.map((feature) => ( <div className="flex" key={feature.id}> <ContextMenu> <ContextMenuTrigger asChild> <button onClick={() => handleViewFeature(feature.id)} type="button" > <GanttFeatureItem onMove={handleMoveFeature} {...feature} > <p className="flex-1 truncate text-xs"> {feature.name} </p> {feature.owner && ( <Avatar className="h-4 w-4"> <AvatarImage src={feature.owner.image} /> <AvatarFallback> {feature.owner.name?.slice(0, 2)} </AvatarFallback> </Avatar> )} </GanttFeatureItem> </button> </ContextMenuTrigger> <ContextMenuContent> <ContextMenuItem className="flex items-center gap-2" onClick={() => handleViewFeature(feature.id)} > <EyeIcon className="text-muted-foreground" size={16} /> View feature </ContextMenuItem> <ContextMenuItem className="flex items-center gap-2" onClick={() => handleCopyLink(feature.id)} > <LinkIcon className="text-muted-foreground" size={16} /> Copy link </ContextMenuItem> <ContextMenuItem className="flex items-center gap-2 text-destructive" onClick={() => handleRemoveFeature(feature.id)} > <TrashIcon size={16} /> Remove from roadmap </ContextMenuItem> </ContextMenuContent> </ContextMenu> </div> ))} </GanttFeatureListGroup> ))} </GanttFeatureList> {exampleMarkers.map((marker) => ( <GanttMarker key={marker.id} {...marker} onRemove={handleRemoveMarker} /> ))} <GanttToday /> <GanttCreateMarkerTrigger onCreateMarker={handleCreateMarker} /> </GanttTimeline> </GanttProvider> );};export default Example;
'use client';import { DndContext, MouseSensor, useDraggable, useSensor,} from '@dnd-kit/core';import { restrictToHorizontalAxis } from '@dnd-kit/modifiers';import { useMouse, useThrottle, useWindowScroll } from '@uidotdev/usehooks';import { addDays, addMonths, differenceInDays, differenceInHours, differenceInMonths, endOfDay, endOfMonth, format, formatDate, formatDistance, getDate, getDaysInMonth, isSameDay, startOfDay, startOfMonth,} from 'date-fns';import { atom, useAtom } from 'jotai';import throttle from 'lodash.throttle';import { PlusIcon, TrashIcon } from 'lucide-react';import type { CSSProperties, FC, KeyboardEventHandler, MouseEventHandler, ReactNode, RefObject,} from 'react';import { createContext, memo, useCallback, useContext, useEffect, useId, useMemo, useRef, useState,} from 'react';import { Card } from '@/components/ui/card';import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger,} from '@/components/ui/context-menu';import { cn } from '@/lib/utils';const draggingAtom = atom(false);const scrollXAtom = atom(0);export const useGanttDragging = () => useAtom(draggingAtom);export const useGanttScrollX = () => useAtom(scrollXAtom);export type GanttStatus = { id: string; name: string; color: string;};export type GanttFeature = { id: string; name: string; startAt: Date; endAt: Date; status: GanttStatus; lane?: string; // Optional: features with the same lane will share a row};export type GanttMarkerProps = { id: string; date: Date; label: string;};export type Range = 'daily' | 'monthly' | 'quarterly';export type TimelineData = { year: number; quarters: { months: { days: number; }[]; }[];}[];export type GanttContextProps = { zoom: number; range: Range; columnWidth: number; sidebarWidth: number; headerHeight: number; rowHeight: number; onAddItem: ((date: Date) => void) | undefined; placeholderLength: number; timelineData: TimelineData; ref: RefObject<HTMLDivElement | null> | null; scrollToFeature?: (feature: GanttFeature) => void;};const getsDaysIn = (range: Range) => { // For when range is daily let fn = (_date: Date) => 1; if (range === 'monthly' || range === 'quarterly') { fn = getDaysInMonth; } return fn;};const getDifferenceIn = (range: Range) => { let fn = differenceInDays; if (range === 'monthly' || range === 'quarterly') { fn = differenceInMonths; } return fn;};const getInnerDifferenceIn = (range: Range) => { let fn = differenceInHours; if (range === 'monthly' || range === 'quarterly') { fn = differenceInDays; } return fn;};const getStartOf = (range: Range) => { let fn = startOfDay; if (range === 'monthly' || range === 'quarterly') { fn = startOfMonth; } return fn;};const getEndOf = (range: Range) => { let fn = endOfDay; if (range === 'monthly' || range === 'quarterly') { fn = endOfMonth; } return fn;};const getAddRange = (range: Range) => { let fn = addDays; if (range === 'monthly' || range === 'quarterly') { fn = addMonths; } return fn;};const getDateByMousePosition = (context: GanttContextProps, mouseX: number) => { const timelineStartDate = new Date(context.timelineData[0].year, 0, 1); const columnWidth = (context.columnWidth * context.zoom) / 100; const offset = Math.floor(mouseX / columnWidth); const daysIn = getsDaysIn(context.range); const addRange = getAddRange(context.range); const month = addRange(timelineStartDate, offset); const daysInMonth = daysIn(month); const pixelsPerDay = Math.round(columnWidth / daysInMonth); const dayOffset = Math.floor((mouseX % columnWidth) / pixelsPerDay); const actualDate = addDays(month, dayOffset); return actualDate;};const createInitialTimelineData = (today: Date) => { const data: TimelineData = []; data.push( { year: today.getFullYear() - 1, quarters: new Array(4).fill(null) }, { year: today.getFullYear(), quarters: new Array(4).fill(null) }, { year: today.getFullYear() + 1, quarters: new Array(4).fill(null) } ); for (const yearObj of data) { yearObj.quarters = new Array(4).fill(null).map((_, quarterIndex) => ({ months: new Array(3).fill(null).map((_, monthIndex) => { const month = quarterIndex * 3 + monthIndex; return { days: getDaysInMonth(new Date(yearObj.year, month, 1)), }; }), })); } return data;};const getOffset = ( date: Date, timelineStartDate: Date, context: GanttContextProps) => { const parsedColumnWidth = (context.columnWidth * context.zoom) / 100; const differenceIn = getDifferenceIn(context.range); const startOf = getStartOf(context.range); const fullColumns = differenceIn(startOf(date), timelineStartDate); if (context.range === 'daily') { return parsedColumnWidth * fullColumns; } const partialColumns = date.getDate(); const daysInMonth = getDaysInMonth(date); const pixelsPerDay = parsedColumnWidth / daysInMonth; return fullColumns * parsedColumnWidth + partialColumns * pixelsPerDay;};const getWidth = ( startAt: Date, endAt: Date | null, context: GanttContextProps) => { const parsedColumnWidth = (context.columnWidth * context.zoom) / 100; if (!endAt) { return parsedColumnWidth * 2; } const differenceIn = getDifferenceIn(context.range); if (context.range === 'daily') { const delta = differenceIn(endAt, startAt); return parsedColumnWidth * (delta ? delta : 1); } const daysInStartMonth = getDaysInMonth(startAt); const pixelsPerDayInStartMonth = parsedColumnWidth / daysInStartMonth; if (isSameDay(startAt, endAt)) { return pixelsPerDayInStartMonth; } const innerDifferenceIn = getInnerDifferenceIn(context.range); const startOf = getStartOf(context.range); if (isSameDay(startOf(startAt), startOf(endAt))) { return innerDifferenceIn(endAt, startAt) * pixelsPerDayInStartMonth; } const startRangeOffset = daysInStartMonth - getDate(startAt); const endRangeOffset = getDate(endAt); const fullRangeOffset = differenceIn(startOf(endAt), startOf(startAt)); const daysInEndMonth = getDaysInMonth(endAt); const pixelsPerDayInEndMonth = parsedColumnWidth / daysInEndMonth; return ( (fullRangeOffset - 1) * parsedColumnWidth + startRangeOffset * pixelsPerDayInStartMonth + endRangeOffset * pixelsPerDayInEndMonth );};const calculateInnerOffset = ( date: Date, range: Range, columnWidth: number) => { const startOf = getStartOf(range); const endOf = getEndOf(range); const differenceIn = getInnerDifferenceIn(range); const startOfRange = startOf(date); const endOfRange = endOf(date); const totalRangeDays = differenceIn(endOfRange, startOfRange); const dayOfMonth = date.getDate(); return (dayOfMonth / totalRangeDays) * columnWidth;};const GanttContext = createContext<GanttContextProps>({ zoom: 100, range: 'monthly', columnWidth: 50, headerHeight: 60, sidebarWidth: 300, rowHeight: 36, onAddItem: undefined, placeholderLength: 2, timelineData: [], ref: null, scrollToFeature: undefined,});export type GanttContentHeaderProps = { renderHeaderItem: (index: number) => ReactNode; title: string; columns: number;};export const GanttContentHeader: FC<GanttContentHeaderProps> = ({ title, columns, renderHeaderItem,}) => { const id = useId(); return ( <div className="sticky top-0 z-20 grid w-full shrink-0 bg-backdrop/90 backdrop-blur-sm" style={{ height: 'var(--gantt-header-height)' }} > <div> <div className="sticky inline-flex whitespace-nowrap px-3 py-2 text-muted-foreground text-xs" style={{ left: 'var(--gantt-sidebar-width)', }} > <p>{title}</p> </div> </div> <div className="grid w-full" style={{ gridTemplateColumns: `repeat(${columns}, var(--gantt-column-width))`, }} > {Array.from({ length: columns }).map((_, index) => ( <div className="shrink-0 border-border/50 border-b py-1 text-center text-xs" key={`${id}-${index}`} > {renderHeaderItem(index)} </div> ))} </div> </div> );};const DailyHeader: FC = () => { const gantt = useContext(GanttContext); return gantt.timelineData.map((year) => year.quarters .flatMap((quarter) => quarter.months) .map((month, index) => ( <div className="relative flex flex-col" key={`${year.year}-${index}`}> <GanttContentHeader columns={month.days} renderHeaderItem={(item: number) => ( <div className="flex items-center justify-center gap-1"> <p> {format(addDays(new Date(year.year, index, 1), item), 'd')} </p> <p className="text-muted-foreground"> {format( addDays(new Date(year.year, index, 1), item), 'EEEEE' )} </p> </div> )} title={format(new Date(year.year, index, 1), 'MMMM yyyy')} /> <GanttColumns columns={month.days} isColumnSecondary={(item: number) => [0, 6].includes( addDays(new Date(year.year, index, 1), item).getDay() ) } /> </div> )) );};const MonthlyHeader: FC = () => { const gantt = useContext(GanttContext); return gantt.timelineData.map((year) => ( <div className="relative flex flex-col" key={year.year}> <GanttContentHeader columns={year.quarters.flatMap((quarter) => quarter.months).length} renderHeaderItem={(item: number) => ( <p>{format(new Date(year.year, item, 1), 'MMM')}</p> )} title={`${year.year}`} /> <GanttColumns columns={year.quarters.flatMap((quarter) => quarter.months).length} /> </div> ));};const QuarterlyHeader: FC = () => { const gantt = useContext(GanttContext); return gantt.timelineData.map((year) => year.quarters.map((quarter, quarterIndex) => ( <div className="relative flex flex-col" key={`${year.year}-${quarterIndex}`} > <GanttContentHeader columns={quarter.months.length} renderHeaderItem={(item: number) => ( <p> {format(new Date(year.year, quarterIndex * 3 + item, 1), 'MMM')} </p> )} title={`Q${quarterIndex + 1} ${year.year}`} /> <GanttColumns columns={quarter.months.length} /> </div> )) );};const headers: Record<Range, FC> = { daily: DailyHeader, monthly: MonthlyHeader, quarterly: QuarterlyHeader,};export type GanttHeaderProps = { className?: string;};export const GanttHeader: FC<GanttHeaderProps> = ({ className }) => { const gantt = useContext(GanttContext); const Header = headers[gantt.range]; return ( <div className={cn( '-space-x-px flex h-full w-max divide-x divide-border/50', className )} > <Header /> </div> );};export type GanttSidebarItemProps = { feature: GanttFeature; onSelectItem?: (id: string) => void; className?: string;};export const GanttSidebarItem: FC<GanttSidebarItemProps> = ({ feature, onSelectItem, className,}) => { const gantt = useContext(GanttContext); const tempEndAt = feature.endAt && isSameDay(feature.startAt, feature.endAt) ? addDays(feature.endAt, 1) : feature.endAt; const duration = tempEndAt ? formatDistance(feature.startAt, tempEndAt) : `${formatDistance(feature.startAt, new Date())} so far`; const handleClick: MouseEventHandler<HTMLDivElement> = (event) => { if (event.target === event.currentTarget) { // Scroll to the feature in the timeline gantt.scrollToFeature?.(feature); // Call the original onSelectItem callback onSelectItem?.(feature.id); } }; const handleKeyDown: KeyboardEventHandler<HTMLDivElement> = (event) => { if (event.key === 'Enter') { // Scroll to the feature in the timeline gantt.scrollToFeature?.(feature); // Call the original onSelectItem callback onSelectItem?.(feature.id); } }; return ( <div className={cn( 'relative flex items-center gap-2.5 p-2.5 text-xs hover:bg-secondary', className )} key={feature.id} onClick={handleClick} onKeyDown={handleKeyDown} // biome-ignore lint/a11y/useSemanticElements: "This is a clickable item" role="button" style={{ height: 'var(--gantt-row-height)', }} tabIndex={0} > {/* <Checkbox onCheckedChange={handleCheck} className="shrink-0" /> */} <div className="pointer-events-none h-2 w-2 shrink-0 rounded-full" style={{ backgroundColor: feature.status.color, }} /> <p className="pointer-events-none flex-1 truncate text-left font-medium"> {feature.name} </p> <p className="pointer-events-none text-muted-foreground">{duration}</p> </div> );};export const GanttSidebarHeader: FC = () => ( <div className="sticky top-0 z-10 flex shrink-0 items-end justify-between gap-2.5 border-border/50 border-b bg-backdrop/90 p-2.5 font-medium text-muted-foreground text-xs backdrop-blur-sm" style={{ height: 'var(--gantt-header-height)' }} > {/* <Checkbox className="shrink-0" /> */} <p className="flex-1 truncate text-left">Issues</p> <p className="shrink-0">Duration</p> </div>);export type GanttSidebarGroupProps = { children: ReactNode; name: string; className?: string;};export const GanttSidebarGroup: FC<GanttSidebarGroupProps> = ({ children, name, className,}) => ( <div className={className}> <p className="w-full truncate p-2.5 text-left font-medium text-muted-foreground text-xs" style={{ height: 'var(--gantt-row-height)' }} > {name} </p> <div className="divide-y divide-border/50">{children}</div> </div>);export type GanttSidebarProps = { children: ReactNode; className?: string;};export const GanttSidebar: FC<GanttSidebarProps> = ({ children, className,}) => ( <div className={cn( 'sticky left-0 z-30 h-max min-h-full overflow-clip border-border/50 border-r bg-background/90 backdrop-blur-md', className )} data-roadmap-ui="gantt-sidebar" > <GanttSidebarHeader /> <div className="space-y-4">{children}</div> </div>);export type GanttAddFeatureHelperProps = { top: number; className?: string;};export const GanttAddFeatureHelper: FC<GanttAddFeatureHelperProps> = ({ top, className,}) => { const [scrollX] = useGanttScrollX(); const gantt = useContext(GanttContext); const [mousePosition, mouseRef] = useMouse<HTMLDivElement>(); const handleClick = () => { const ganttRect = gantt.ref?.current?.getBoundingClientRect(); const x = mousePosition.x - (ganttRect?.left ?? 0) + scrollX - gantt.sidebarWidth; const currentDate = getDateByMousePosition(gantt, x); gantt.onAddItem?.(currentDate); }; return ( <div className={cn('absolute top-0 w-full px-0.5', className)} ref={mouseRef} style={{ marginTop: -gantt.rowHeight / 2, transform: `translateY(${top}px)`, }} > <button className="flex h-full w-full items-center justify-center rounded-md border border-dashed p-2" onClick={handleClick} type="button" > <PlusIcon className="pointer-events-none select-none text-muted-foreground" size={16} /> </button> </div> );};export type GanttColumnProps = { index: number; isColumnSecondary?: (item: number) => boolean;};export const GanttColumn: FC<GanttColumnProps> = ({ index, isColumnSecondary,}) => { const gantt = useContext(GanttContext); const [dragging] = useGanttDragging(); const [mousePosition, mouseRef] = useMouse<HTMLDivElement>(); const [hovering, setHovering] = useState(false); const [windowScroll] = useWindowScroll(); const handleMouseEnter = () => setHovering(true); const handleMouseLeave = () => setHovering(false); const top = useThrottle( mousePosition.y - (mouseRef.current?.getBoundingClientRect().y ?? 0) - (windowScroll.y ?? 0), 10 ); return ( // biome-ignore lint/a11y/noStaticElementInteractions: "This is a clickable column" // biome-ignore lint/nursery/noNoninteractiveElementInteractions: "This is a clickable column" <div className={cn( 'group relative h-full overflow-hidden', isColumnSecondary?.(index) ? 'bg-secondary' : '' )} onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave} ref={mouseRef} > {!dragging && hovering && gantt.onAddItem ? ( <GanttAddFeatureHelper top={top} /> ) : null} </div> );};export type GanttColumnsProps = { columns: number; isColumnSecondary?: (item: number) => boolean;};export const GanttColumns: FC<GanttColumnsProps> = ({ columns, isColumnSecondary,}) => { const id = useId(); return ( <div className="divide grid h-full w-full divide-x divide-border/50" style={{ gridTemplateColumns: `repeat(${columns}, var(--gantt-column-width))`, }} > {Array.from({ length: columns }).map((_, index) => ( <GanttColumn index={index} isColumnSecondary={isColumnSecondary} key={`${id}-${index}`} /> ))} </div> );};export type GanttCreateMarkerTriggerProps = { onCreateMarker: (date: Date) => void; className?: string;};export const GanttCreateMarkerTrigger: FC<GanttCreateMarkerTriggerProps> = ({ onCreateMarker, className,}) => { const gantt = useContext(GanttContext); const [mousePosition, mouseRef] = useMouse<HTMLDivElement>(); const [windowScroll] = useWindowScroll(); const x = useThrottle( mousePosition.x - (mouseRef.current?.getBoundingClientRect().x ?? 0) - (windowScroll.x ?? 0), 10 ); const date = getDateByMousePosition(gantt, x); const handleClick = () => onCreateMarker(date); return ( <div className={cn( 'group pointer-events-none absolute top-0 left-0 h-full w-full select-none overflow-visible', className )} ref={mouseRef} > <div className="-ml-2 pointer-events-auto sticky top-6 z-20 flex w-4 flex-col items-center justify-center gap-1 overflow-visible opacity-0 group-hover:opacity-100" style={{ transform: `translateX(${x}px)` }} > <button className="z-50 inline-flex h-4 w-4 items-center justify-center rounded-full bg-card" onClick={handleClick} type="button" > <PlusIcon className="text-muted-foreground" size={12} /> </button> <div className="whitespace-nowrap rounded-full border border-border/50 bg-background/90 px-2 py-1 text-foreground text-xs backdrop-blur-lg"> {formatDate(date, 'MMM dd, yyyy')} </div> </div> </div> );};export type GanttFeatureDragHelperProps = { featureId: GanttFeature['id']; direction: 'left' | 'right'; date: Date | null;};export const GanttFeatureDragHelper: FC<GanttFeatureDragHelperProps> = ({ direction, featureId, date,}) => { const [, setDragging] = useGanttDragging(); const { attributes, listeners, setNodeRef } = useDraggable({ id: `feature-drag-helper-${featureId}`, }); const isPressed = Boolean(attributes['aria-pressed']); useEffect(() => setDragging(isPressed), [isPressed, setDragging]); return ( <div className={cn( 'group -translate-y-1/2 !cursor-col-resize absolute top-1/2 z-[3] h-full w-6 rounded-md outline-none', direction === 'left' ? '-left-2.5' : '-right-2.5' )} ref={setNodeRef} {...attributes} {...listeners} > <div className={cn( '-translate-y-1/2 absolute top-1/2 h-[80%] w-1 rounded-sm bg-muted-foreground opacity-0 transition-all', direction === 'left' ? 'left-2.5' : 'right-2.5', direction === 'left' ? 'group-hover:left-0' : 'group-hover:right-0', isPressed && (direction === 'left' ? 'left-0' : 'right-0'), 'group-hover:opacity-100', isPressed && 'opacity-100' )} /> {date && ( <div className={cn( '-translate-x-1/2 absolute top-10 hidden whitespace-nowrap rounded-lg border border-border/50 bg-background/90 px-2 py-1 text-foreground text-xs backdrop-blur-lg group-hover:block', isPressed && 'block' )} > {format(date, 'MMM dd, yyyy')} </div> )} </div> );};export type GanttFeatureItemCardProps = Pick<GanttFeature, 'id'> & { children?: ReactNode;};export const GanttFeatureItemCard: FC<GanttFeatureItemCardProps> = ({ id, children,}) => { const [, setDragging] = useGanttDragging(); const { attributes, listeners, setNodeRef } = useDraggable({ id }); const isPressed = Boolean(attributes['aria-pressed']); useEffect(() => setDragging(isPressed), [isPressed, setDragging]); return ( <Card className="h-full w-full rounded-md bg-background p-2 text-xs shadow-sm"> <div className={cn( 'flex h-full w-full items-center justify-between gap-2 text-left', isPressed && 'cursor-grabbing' )} {...attributes} {...listeners} ref={setNodeRef} > {children} </div> </Card> );};export type GanttFeatureItemProps = GanttFeature & { onMove?: (id: string, startDate: Date, endDate: Date | null) => void; children?: ReactNode; className?: string;};export const GanttFeatureItem: FC<GanttFeatureItemProps> = ({ onMove, children, className, ...feature}) => { const [scrollX] = useGanttScrollX(); const gantt = useContext(GanttContext); const timelineStartDate = useMemo( () => new Date(gantt.timelineData.at(0)?.year ?? 0, 0, 1), [gantt.timelineData] ); const [startAt, setStartAt] = useState<Date>(feature.startAt); const [endAt, setEndAt] = useState<Date | null>(feature.endAt); // Memoize expensive calculations const width = useMemo( () => getWidth(startAt, endAt, gantt), [startAt, endAt, gantt] ); const offset = useMemo( () => getOffset(startAt, timelineStartDate, gantt), [startAt, timelineStartDate, gantt] ); const addRange = useMemo(() => getAddRange(gantt.range), [gantt.range]); const [mousePosition] = useMouse<HTMLDivElement>(); const [previousMouseX, setPreviousMouseX] = useState(0); const [previousStartAt, setPreviousStartAt] = useState(startAt); const [previousEndAt, setPreviousEndAt] = useState(endAt); const mouseSensor = useSensor(MouseSensor, { activationConstraint: { distance: 10, }, }); const handleItemDragStart = useCallback(() => { setPreviousMouseX(mousePosition.x); setPreviousStartAt(startAt); setPreviousEndAt(endAt); }, [mousePosition.x, startAt, endAt]); const handleItemDragMove = useCallback(() => { const currentDate = getDateByMousePosition(gantt, mousePosition.x); const originalDate = getDateByMousePosition(gantt, previousMouseX); const delta = gantt.range === 'daily' ? getDifferenceIn(gantt.range)(currentDate, originalDate) : getInnerDifferenceIn(gantt.range)(currentDate, originalDate); const newStartDate = addDays(previousStartAt, delta); const newEndDate = previousEndAt ? addDays(previousEndAt, delta) : null; setStartAt(newStartDate); setEndAt(newEndDate); }, [gantt, mousePosition.x, previousMouseX, previousStartAt, previousEndAt]); const onDragEnd = useCallback( () => onMove?.(feature.id, startAt, endAt), [onMove, feature.id, startAt, endAt] ); const handleLeftDragMove = useCallback(() => { const ganttRect = gantt.ref?.current?.getBoundingClientRect(); const x = mousePosition.x - (ganttRect?.left ?? 0) + scrollX - gantt.sidebarWidth; const newStartAt = getDateByMousePosition(gantt, x); setStartAt(newStartAt); }, [gantt, mousePosition.x, scrollX]); const handleRightDragMove = useCallback(() => { const ganttRect = gantt.ref?.current?.getBoundingClientRect(); const x = mousePosition.x - (ganttRect?.left ?? 0) + scrollX - gantt.sidebarWidth; const newEndAt = getDateByMousePosition(gantt, x); setEndAt(newEndAt); }, [gantt, mousePosition.x, scrollX]); return ( <div className={cn('relative flex w-max min-w-full py-0.5', className)} style={{ height: 'var(--gantt-row-height)' }} > <div className="pointer-events-auto absolute top-0.5" style={{ height: 'calc(var(--gantt-row-height) - 4px)', width: Math.round(width), left: Math.round(offset), }} > {onMove && ( <DndContext modifiers={[restrictToHorizontalAxis]} onDragEnd={onDragEnd} onDragMove={handleLeftDragMove} sensors={[mouseSensor]} > <GanttFeatureDragHelper date={startAt} direction="left" featureId={feature.id} /> </DndContext> )} <DndContext modifiers={[restrictToHorizontalAxis]} onDragEnd={onDragEnd} onDragMove={handleItemDragMove} onDragStart={handleItemDragStart} sensors={[mouseSensor]} > <GanttFeatureItemCard id={feature.id}> {children ?? ( <p className="flex-1 truncate text-xs">{feature.name}</p> )} </GanttFeatureItemCard> </DndContext> {onMove && ( <DndContext modifiers={[restrictToHorizontalAxis]} onDragEnd={onDragEnd} onDragMove={handleRightDragMove} sensors={[mouseSensor]} > <GanttFeatureDragHelper date={endAt ?? addRange(startAt, 2)} direction="right" featureId={feature.id} /> </DndContext> )} </div> </div> );};export type GanttFeatureListGroupProps = { children: ReactNode; className?: string;};export const GanttFeatureListGroup: FC<GanttFeatureListGroupProps> = ({ children, className,}) => ( <div className={className} style={{ paddingTop: 'var(--gantt-row-height)' }}> {children} </div>);export type GanttFeatureRowProps = { features: GanttFeature[]; onMove?: (id: string, startAt: Date, endAt: Date | null) => void; children?: (feature: GanttFeature) => ReactNode; className?: string;};export const GanttFeatureRow: FC<GanttFeatureRowProps> = ({ features, onMove, children, className,}) => { // Sort features by start date to handle potential overlaps const sortedFeatures = [...features].sort((a, b) => a.startAt.getTime() - b.startAt.getTime() ); // Calculate sub-row positions for overlapping features using a proper algorithm const featureWithPositions = []; const subRowEndTimes: Date[] = []; // Track when each sub-row becomes free for (const feature of sortedFeatures) { let subRow = 0; // Find the first sub-row that's free (doesn't overlap) while (subRow < subRowEndTimes.length && subRowEndTimes[subRow] > feature.startAt) { subRow++; } // Update the end time for this sub-row if (subRow === subRowEndTimes.length) { subRowEndTimes.push(feature.endAt); } else { subRowEndTimes[subRow] = feature.endAt; } featureWithPositions.push({ ...feature, subRow }); } const maxSubRows = Math.max(1, subRowEndTimes.length); const subRowHeight = 36; // Base row height return ( <div className={cn('relative', className)} style={{ height: `${maxSubRows * subRowHeight}px`, minHeight: 'var(--gantt-row-height)' }} > {featureWithPositions.map((feature) => ( <div key={feature.id} className="absolute w-full" style={{ top: `${feature.subRow * subRowHeight}px`, height: `${subRowHeight}px` }} > <GanttFeatureItem {...feature} onMove={onMove} > {children ? children(feature) : ( <p className="flex-1 truncate text-xs">{feature.name}</p> )} </GanttFeatureItem> </div> ))} </div> );};export type GanttFeatureListProps = { className?: string; children: ReactNode;};export const GanttFeatureList: FC<GanttFeatureListProps> = ({ className, children,}) => ( <div className={cn('absolute top-0 left-0 h-full w-max space-y-4', className)} style={{ marginTop: 'var(--gantt-header-height)' }} > {children} </div>);export const GanttMarker: FC< GanttMarkerProps & { onRemove?: (id: string) => void; className?: string; }> = memo(({ label, date, id, onRemove, className }) => { const gantt = useContext(GanttContext); const differenceIn = useMemo( () => getDifferenceIn(gantt.range), [gantt.range] ); const timelineStartDate = useMemo( () => new Date(gantt.timelineData.at(0)?.year ?? 0, 0, 1), [gantt.timelineData] ); // Memoize expensive calculations const offset = useMemo( () => differenceIn(date, timelineStartDate), [differenceIn, date, timelineStartDate] ); const innerOffset = useMemo( () => calculateInnerOffset( date, gantt.range, (gantt.columnWidth * gantt.zoom) / 100 ), [date, gantt.range, gantt.columnWidth, gantt.zoom] ); const handleRemove = useCallback(() => onRemove?.(id), [onRemove, id]); return ( <div className="pointer-events-none absolute top-0 left-0 z-20 flex h-full select-none flex-col items-center justify-center overflow-visible" style={{ width: 0, transform: `translateX(calc(var(--gantt-column-width) * ${offset} + ${innerOffset}px))`, }} > <ContextMenu> <ContextMenuTrigger asChild> <div className={cn( 'group pointer-events-auto sticky top-0 flex select-auto flex-col flex-nowrap items-center justify-center whitespace-nowrap rounded-b-md bg-card px-2 py-1 text-foreground text-xs', className )} > {label} <span className="max-h-[0] overflow-hidden opacity-80 transition-all group-hover:max-h-[2rem]"> {formatDate(date, 'MMM dd, yyyy')} </span> </div> </ContextMenuTrigger> <ContextMenuContent> {onRemove ? ( <ContextMenuItem className="flex items-center gap-2 text-destructive" onClick={handleRemove} > <TrashIcon size={16} /> Remove marker </ContextMenuItem> ) : null} </ContextMenuContent> </ContextMenu> <div className={cn('h-full w-px bg-card', className)} /> </div> );});GanttMarker.displayName = 'GanttMarker';export type GanttProviderProps = { range?: Range; zoom?: number; onAddItem?: (date: Date) => void; children: ReactNode; className?: string;};export const GanttProvider: FC<GanttProviderProps> = ({ zoom = 100, range = 'monthly', onAddItem, children, className,}) => { const scrollRef = useRef<HTMLDivElement>(null); const [timelineData, setTimelineData] = useState<TimelineData>( createInitialTimelineData(new Date()) ); const [, setScrollX] = useGanttScrollX(); const [sidebarWidth, setSidebarWidth] = useState(0); const headerHeight = 60; const rowHeight = 36; let columnWidth = 50; if (range === 'monthly') { columnWidth = 150; } else if (range === 'quarterly') { columnWidth = 100; } // Memoize CSS variables to prevent unnecessary re-renders const cssVariables = useMemo( () => ({ '--gantt-zoom': `${zoom}`, '--gantt-column-width': `${(zoom / 100) * columnWidth}px`, '--gantt-header-height': `${headerHeight}px`, '--gantt-row-height': `${rowHeight}px`, '--gantt-sidebar-width': `${sidebarWidth}px`, }) as CSSProperties, [zoom, columnWidth, sidebarWidth] ); useEffect(() => { if (scrollRef.current) { scrollRef.current.scrollLeft = scrollRef.current.scrollWidth / 2 - scrollRef.current.clientWidth / 2; setScrollX(scrollRef.current.scrollLeft); } }, [setScrollX]); // Update sidebar width when DOM is ready useEffect(() => { const updateSidebarWidth = () => { const sidebarElement = scrollRef.current?.querySelector( '[data-roadmap-ui="gantt-sidebar"]' ); const newWidth = sidebarElement ? 300 : 0; setSidebarWidth(newWidth); }; // Update immediately updateSidebarWidth(); // Also update on resize or when children change const observer = new MutationObserver(updateSidebarWidth); if (scrollRef.current) { observer.observe(scrollRef.current, { childList: true, subtree: true, }); } return () => { observer.disconnect(); }; }, []); // Fix the useCallback to include all dependencies const handleScroll = useCallback( throttle(() => { const scrollElement = scrollRef.current; if (!scrollElement) { return; } const { scrollLeft, scrollWidth, clientWidth } = scrollElement; setScrollX(scrollLeft); if (scrollLeft === 0) { // Extend timelineData to the past const firstYear = timelineData[0]?.year; if (!firstYear) { return; } const newTimelineData: TimelineData = [...timelineData]; newTimelineData.unshift({ year: firstYear - 1, quarters: new Array(4).fill(null).map((_, quarterIndex) => ({ months: new Array(3).fill(null).map((_, monthIndex) => { const month = quarterIndex * 3 + monthIndex; return { days: getDaysInMonth(new Date(firstYear, month, 1)), }; }), })), }); setTimelineData(newTimelineData); // Scroll a bit forward so it's not at the very start scrollElement.scrollLeft = scrollElement.clientWidth; setScrollX(scrollElement.scrollLeft); } else if (scrollLeft + clientWidth >= scrollWidth) { // Extend timelineData to the future const lastYear = timelineData.at(-1)?.year; if (!lastYear) { return; } const newTimelineData: TimelineData = [...timelineData]; newTimelineData.push({ year: lastYear + 1, quarters: new Array(4).fill(null).map((_, quarterIndex) => ({ months: new Array(3).fill(null).map((_, monthIndex) => { const month = quarterIndex * 3 + monthIndex; return { days: getDaysInMonth(new Date(lastYear, month, 1)), }; }), })), }); setTimelineData(newTimelineData); // Scroll a bit back so it's not at the very end scrollElement.scrollLeft = scrollElement.scrollWidth - scrollElement.clientWidth; setScrollX(scrollElement.scrollLeft); } }, 100), [] ); useEffect(() => { const scrollElement = scrollRef.current; if (scrollElement) { scrollElement.addEventListener('scroll', handleScroll); } return () => { // Fix memory leak by properly referencing the scroll element if (scrollElement) { scrollElement.removeEventListener('scroll', handleScroll); } }; }, [handleScroll]); const scrollToFeature = useCallback((feature: GanttFeature) => { const scrollElement = scrollRef.current; if (!scrollElement) { return; } // Calculate timeline start date from timelineData const timelineStartDate = new Date(timelineData[0].year, 0, 1); // Calculate the horizontal offset for the feature's start date const offset = getOffset(feature.startAt, timelineStartDate, { zoom, range, columnWidth, sidebarWidth, headerHeight, rowHeight, onAddItem, placeholderLength: 2, timelineData, ref: scrollRef, }); // Scroll to align the feature's start with the right side of the sidebar const targetScrollLeft = Math.max(0, offset); scrollElement.scrollTo({ left: targetScrollLeft, behavior: 'smooth', }); }, [timelineData, zoom, range, columnWidth, sidebarWidth, headerHeight, rowHeight, onAddItem]); return ( <GanttContext.Provider value={{ zoom, range, headerHeight, columnWidth, sidebarWidth, rowHeight, onAddItem, timelineData, placeholderLength: 2, ref: scrollRef, scrollToFeature, }} > <div className={cn( 'gantt relative grid h-full w-full flex-none select-none overflow-auto rounded-sm bg-secondary', range, className )} ref={scrollRef} style={{ ...cssVariables, gridTemplateColumns: 'var(--gantt-sidebar-width) 1fr', }} > {children} </div> </GanttContext.Provider> );};export type GanttTimelineProps = { children: ReactNode; className?: string;};export const GanttTimeline: FC<GanttTimelineProps> = ({ children, className,}) => ( <div className={cn( 'relative flex h-full w-max flex-none overflow-clip', className )} > {children} </div>);export type GanttTodayProps = { className?: string;};export const GanttToday: FC<GanttTodayProps> = ({ className }) => { const label = 'Today'; const date = useMemo(() => new Date(), []); const gantt = useContext(GanttContext); const differenceIn = useMemo( () => getDifferenceIn(gantt.range), [gantt.range] ); const timelineStartDate = useMemo( () => new Date(gantt.timelineData.at(0)?.year ?? 0, 0, 1), [gantt.timelineData] ); // Memoize expensive calculations const offset = useMemo( () => differenceIn(date, timelineStartDate), [differenceIn, date, timelineStartDate] ); const innerOffset = useMemo( () => calculateInnerOffset( date, gantt.range, (gantt.columnWidth * gantt.zoom) / 100 ), [date, gantt.range, gantt.columnWidth, gantt.zoom] ); return ( <div className="pointer-events-none absolute top-0 left-0 z-20 flex h-full select-none flex-col items-center justify-center overflow-visible" style={{ width: 0, transform: `translateX(calc(var(--gantt-column-width) * ${offset} + ${innerOffset}px))`, }} > <div className={cn( 'group pointer-events-auto sticky top-0 flex select-auto flex-col flex-nowrap items-center justify-center whitespace-nowrap rounded-b-md bg-card px-2 py-1 text-foreground text-xs', className )} > {label} <span className="max-h-[0] overflow-hidden opacity-80 transition-all group-hover:max-h-[2rem]"> {formatDate(date, 'MMM dd, yyyy')} </span> </div> <div className={cn('h-full w-px bg-card', className)} /> </div> );};
Read-only version
'use client';import { faker } from '@faker-js/faker';import { GanttFeatureItem, GanttFeatureList, GanttFeatureListGroup, GanttHeader, GanttMarker, GanttProvider, GanttSidebar, GanttSidebarGroup, GanttSidebarItem, GanttTimeline, GanttToday,} from '@/components/ui/shadcn-io/gantt';import groupBy from 'lodash.groupby';const capitalize = (str: string) => str.charAt(0).toUpperCase() + str.slice(1);const statuses = [ { id: faker.string.uuid(), name: 'Planned', color: '#6B7280' }, { id: faker.string.uuid(), name: 'In Progress', color: '#F59E0B' }, { id: faker.string.uuid(), name: 'Done', color: '#10B981' },];const users = Array.from({ length: 4 }) .fill(null) .map(() => ({ id: faker.string.uuid(), name: faker.person.fullName(), image: faker.image.avatar(), }));const exampleGroups = Array.from({ length: 6 }) .fill(null) .map(() => ({ id: faker.string.uuid(), name: capitalize(faker.company.buzzPhrase()), }));const exampleProducts = Array.from({ length: 4 }) .fill(null) .map(() => ({ id: faker.string.uuid(), name: capitalize(faker.company.buzzPhrase()), }));const exampleInitiatives = Array.from({ length: 2 }) .fill(null) .map(() => ({ id: faker.string.uuid(), name: capitalize(faker.company.buzzPhrase()), }));const exampleReleases = Array.from({ length: 3 }) .fill(null) .map(() => ({ id: faker.string.uuid(), name: capitalize(faker.company.buzzPhrase()), }));const exampleFeatures = Array.from({ length: 20 }) .fill(null) .map(() => ({ id: faker.string.uuid(), name: capitalize(faker.company.buzzPhrase()), startAt: faker.date.past({ years: 0.5, refDate: new Date() }), endAt: faker.date.future({ years: 0.5, refDate: new Date() }), status: faker.helpers.arrayElement(statuses), owner: faker.helpers.arrayElement(users), group: faker.helpers.arrayElement(exampleGroups), product: faker.helpers.arrayElement(exampleProducts), initiative: faker.helpers.arrayElement(exampleInitiatives), release: faker.helpers.arrayElement(exampleReleases), }));const exampleMarkers = Array.from({ length: 6 }) .fill(null) .map(() => ({ id: faker.string.uuid(), date: faker.date.past({ years: 0.5, refDate: new Date() }), label: capitalize(faker.company.buzzPhrase()), className: faker.helpers.arrayElement([ 'bg-blue-100 text-blue-900', 'bg-green-100 text-green-900', 'bg-purple-100 text-purple-900', 'bg-red-100 text-red-900', 'bg-orange-100 text-orange-900', 'bg-teal-100 text-teal-900', ]), }));const Example = () => { const groupedFeatures = groupBy(exampleFeatures, 'group.name'); const sortedGroupedFeatures = Object.fromEntries( Object.entries(groupedFeatures).sort(([nameA], [nameB]) => nameA.localeCompare(nameB) ) ); return ( <GanttProvider className="border" range="monthly" zoom={100}> <GanttSidebar> {Object.entries(sortedGroupedFeatures).map(([group, features]) => ( <GanttSidebarGroup key={group} name={group}> {features.map((feature) => ( <GanttSidebarItem feature={feature} key={feature.id} /> ))} </GanttSidebarGroup> ))} </GanttSidebar> <GanttTimeline> <GanttHeader /> <GanttFeatureList> {Object.entries(sortedGroupedFeatures).map(([group, features]) => ( <GanttFeatureListGroup key={group}> {features.map((feature) => ( <div className="flex" key={feature.id}> <GanttFeatureItem {...feature} /> </div> ))} </GanttFeatureListGroup> ))} </GanttFeatureList> {exampleMarkers.map((marker) => ( <GanttMarker key={marker.id} {...marker} /> ))} <GanttToday /> </GanttTimeline> </GanttProvider> );};export default Example;
'use client';import { DndContext, MouseSensor, useDraggable, useSensor,} from '@dnd-kit/core';import { restrictToHorizontalAxis } from '@dnd-kit/modifiers';import { useMouse, useThrottle, useWindowScroll } from '@uidotdev/usehooks';import { addDays, addMonths, differenceInDays, differenceInHours, differenceInMonths, endOfDay, endOfMonth, format, formatDate, formatDistance, getDate, getDaysInMonth, isSameDay, startOfDay, startOfMonth,} from 'date-fns';import { atom, useAtom } from 'jotai';import throttle from 'lodash.throttle';import { PlusIcon, TrashIcon } from 'lucide-react';import type { CSSProperties, FC, KeyboardEventHandler, MouseEventHandler, ReactNode, RefObject,} from 'react';import { createContext, memo, useCallback, useContext, useEffect, useId, useMemo, useRef, useState,} from 'react';import { Card } from '@/components/ui/card';import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger,} from '@/components/ui/context-menu';import { cn } from '@/lib/utils';const draggingAtom = atom(false);const scrollXAtom = atom(0);export const useGanttDragging = () => useAtom(draggingAtom);export const useGanttScrollX = () => useAtom(scrollXAtom);export type GanttStatus = { id: string; name: string; color: string;};export type GanttFeature = { id: string; name: string; startAt: Date; endAt: Date; status: GanttStatus; lane?: string; // Optional: features with the same lane will share a row};export type GanttMarkerProps = { id: string; date: Date; label: string;};export type Range = 'daily' | 'monthly' | 'quarterly';export type TimelineData = { year: number; quarters: { months: { days: number; }[]; }[];}[];export type GanttContextProps = { zoom: number; range: Range; columnWidth: number; sidebarWidth: number; headerHeight: number; rowHeight: number; onAddItem: ((date: Date) => void) | undefined; placeholderLength: number; timelineData: TimelineData; ref: RefObject<HTMLDivElement | null> | null; scrollToFeature?: (feature: GanttFeature) => void;};const getsDaysIn = (range: Range) => { // For when range is daily let fn = (_date: Date) => 1; if (range === 'monthly' || range === 'quarterly') { fn = getDaysInMonth; } return fn;};const getDifferenceIn = (range: Range) => { let fn = differenceInDays; if (range === 'monthly' || range === 'quarterly') { fn = differenceInMonths; } return fn;};const getInnerDifferenceIn = (range: Range) => { let fn = differenceInHours; if (range === 'monthly' || range === 'quarterly') { fn = differenceInDays; } return fn;};const getStartOf = (range: Range) => { let fn = startOfDay; if (range === 'monthly' || range === 'quarterly') { fn = startOfMonth; } return fn;};const getEndOf = (range: Range) => { let fn = endOfDay; if (range === 'monthly' || range === 'quarterly') { fn = endOfMonth; } return fn;};const getAddRange = (range: Range) => { let fn = addDays; if (range === 'monthly' || range === 'quarterly') { fn = addMonths; } return fn;};const getDateByMousePosition = (context: GanttContextProps, mouseX: number) => { const timelineStartDate = new Date(context.timelineData[0].year, 0, 1); const columnWidth = (context.columnWidth * context.zoom) / 100; const offset = Math.floor(mouseX / columnWidth); const daysIn = getsDaysIn(context.range); const addRange = getAddRange(context.range); const month = addRange(timelineStartDate, offset); const daysInMonth = daysIn(month); const pixelsPerDay = Math.round(columnWidth / daysInMonth); const dayOffset = Math.floor((mouseX % columnWidth) / pixelsPerDay); const actualDate = addDays(month, dayOffset); return actualDate;};const createInitialTimelineData = (today: Date) => { const data: TimelineData = []; data.push( { year: today.getFullYear() - 1, quarters: new Array(4).fill(null) }, { year: today.getFullYear(), quarters: new Array(4).fill(null) }, { year: today.getFullYear() + 1, quarters: new Array(4).fill(null) } ); for (const yearObj of data) { yearObj.quarters = new Array(4).fill(null).map((_, quarterIndex) => ({ months: new Array(3).fill(null).map((_, monthIndex) => { const month = quarterIndex * 3 + monthIndex; return { days: getDaysInMonth(new Date(yearObj.year, month, 1)), }; }), })); } return data;};const getOffset = ( date: Date, timelineStartDate: Date, context: GanttContextProps) => { const parsedColumnWidth = (context.columnWidth * context.zoom) / 100; const differenceIn = getDifferenceIn(context.range); const startOf = getStartOf(context.range); const fullColumns = differenceIn(startOf(date), timelineStartDate); if (context.range === 'daily') { return parsedColumnWidth * fullColumns; } const partialColumns = date.getDate(); const daysInMonth = getDaysInMonth(date); const pixelsPerDay = parsedColumnWidth / daysInMonth; return fullColumns * parsedColumnWidth + partialColumns * pixelsPerDay;};const getWidth = ( startAt: Date, endAt: Date | null, context: GanttContextProps) => { const parsedColumnWidth = (context.columnWidth * context.zoom) / 100; if (!endAt) { return parsedColumnWidth * 2; } const differenceIn = getDifferenceIn(context.range); if (context.range === 'daily') { const delta = differenceIn(endAt, startAt); return parsedColumnWidth * (delta ? delta : 1); } const daysInStartMonth = getDaysInMonth(startAt); const pixelsPerDayInStartMonth = parsedColumnWidth / daysInStartMonth; if (isSameDay(startAt, endAt)) { return pixelsPerDayInStartMonth; } const innerDifferenceIn = getInnerDifferenceIn(context.range); const startOf = getStartOf(context.range); if (isSameDay(startOf(startAt), startOf(endAt))) { return innerDifferenceIn(endAt, startAt) * pixelsPerDayInStartMonth; } const startRangeOffset = daysInStartMonth - getDate(startAt); const endRangeOffset = getDate(endAt); const fullRangeOffset = differenceIn(startOf(endAt), startOf(startAt)); const daysInEndMonth = getDaysInMonth(endAt); const pixelsPerDayInEndMonth = parsedColumnWidth / daysInEndMonth; return ( (fullRangeOffset - 1) * parsedColumnWidth + startRangeOffset * pixelsPerDayInStartMonth + endRangeOffset * pixelsPerDayInEndMonth );};const calculateInnerOffset = ( date: Date, range: Range, columnWidth: number) => { const startOf = getStartOf(range); const endOf = getEndOf(range); const differenceIn = getInnerDifferenceIn(range); const startOfRange = startOf(date); const endOfRange = endOf(date); const totalRangeDays = differenceIn(endOfRange, startOfRange); const dayOfMonth = date.getDate(); return (dayOfMonth / totalRangeDays) * columnWidth;};const GanttContext = createContext<GanttContextProps>({ zoom: 100, range: 'monthly', columnWidth: 50, headerHeight: 60, sidebarWidth: 300, rowHeight: 36, onAddItem: undefined, placeholderLength: 2, timelineData: [], ref: null, scrollToFeature: undefined,});export type GanttContentHeaderProps = { renderHeaderItem: (index: number) => ReactNode; title: string; columns: number;};export const GanttContentHeader: FC<GanttContentHeaderProps> = ({ title, columns, renderHeaderItem,}) => { const id = useId(); return ( <div className="sticky top-0 z-20 grid w-full shrink-0 bg-backdrop/90 backdrop-blur-sm" style={{ height: 'var(--gantt-header-height)' }} > <div> <div className="sticky inline-flex whitespace-nowrap px-3 py-2 text-muted-foreground text-xs" style={{ left: 'var(--gantt-sidebar-width)', }} > <p>{title}</p> </div> </div> <div className="grid w-full" style={{ gridTemplateColumns: `repeat(${columns}, var(--gantt-column-width))`, }} > {Array.from({ length: columns }).map((_, index) => ( <div className="shrink-0 border-border/50 border-b py-1 text-center text-xs" key={`${id}-${index}`} > {renderHeaderItem(index)} </div> ))} </div> </div> );};const DailyHeader: FC = () => { const gantt = useContext(GanttContext); return gantt.timelineData.map((year) => year.quarters .flatMap((quarter) => quarter.months) .map((month, index) => ( <div className="relative flex flex-col" key={`${year.year}-${index}`}> <GanttContentHeader columns={month.days} renderHeaderItem={(item: number) => ( <div className="flex items-center justify-center gap-1"> <p> {format(addDays(new Date(year.year, index, 1), item), 'd')} </p> <p className="text-muted-foreground"> {format( addDays(new Date(year.year, index, 1), item), 'EEEEE' )} </p> </div> )} title={format(new Date(year.year, index, 1), 'MMMM yyyy')} /> <GanttColumns columns={month.days} isColumnSecondary={(item: number) => [0, 6].includes( addDays(new Date(year.year, index, 1), item).getDay() ) } /> </div> )) );};const MonthlyHeader: FC = () => { const gantt = useContext(GanttContext); return gantt.timelineData.map((year) => ( <div className="relative flex flex-col" key={year.year}> <GanttContentHeader columns={year.quarters.flatMap((quarter) => quarter.months).length} renderHeaderItem={(item: number) => ( <p>{format(new Date(year.year, item, 1), 'MMM')}</p> )} title={`${year.year}`} /> <GanttColumns columns={year.quarters.flatMap((quarter) => quarter.months).length} /> </div> ));};const QuarterlyHeader: FC = () => { const gantt = useContext(GanttContext); return gantt.timelineData.map((year) => year.quarters.map((quarter, quarterIndex) => ( <div className="relative flex flex-col" key={`${year.year}-${quarterIndex}`} > <GanttContentHeader columns={quarter.months.length} renderHeaderItem={(item: number) => ( <p> {format(new Date(year.year, quarterIndex * 3 + item, 1), 'MMM')} </p> )} title={`Q${quarterIndex + 1} ${year.year}`} /> <GanttColumns columns={quarter.months.length} /> </div> )) );};const headers: Record<Range, FC> = { daily: DailyHeader, monthly: MonthlyHeader, quarterly: QuarterlyHeader,};export type GanttHeaderProps = { className?: string;};export const GanttHeader: FC<GanttHeaderProps> = ({ className }) => { const gantt = useContext(GanttContext); const Header = headers[gantt.range]; return ( <div className={cn( '-space-x-px flex h-full w-max divide-x divide-border/50', className )} > <Header /> </div> );};export type GanttSidebarItemProps = { feature: GanttFeature; onSelectItem?: (id: string) => void; className?: string;};export const GanttSidebarItem: FC<GanttSidebarItemProps> = ({ feature, onSelectItem, className,}) => { const gantt = useContext(GanttContext); const tempEndAt = feature.endAt && isSameDay(feature.startAt, feature.endAt) ? addDays(feature.endAt, 1) : feature.endAt; const duration = tempEndAt ? formatDistance(feature.startAt, tempEndAt) : `${formatDistance(feature.startAt, new Date())} so far`; const handleClick: MouseEventHandler<HTMLDivElement> = (event) => { if (event.target === event.currentTarget) { // Scroll to the feature in the timeline gantt.scrollToFeature?.(feature); // Call the original onSelectItem callback onSelectItem?.(feature.id); } }; const handleKeyDown: KeyboardEventHandler<HTMLDivElement> = (event) => { if (event.key === 'Enter') { // Scroll to the feature in the timeline gantt.scrollToFeature?.(feature); // Call the original onSelectItem callback onSelectItem?.(feature.id); } }; return ( <div className={cn( 'relative flex items-center gap-2.5 p-2.5 text-xs hover:bg-secondary', className )} key={feature.id} onClick={handleClick} onKeyDown={handleKeyDown} // biome-ignore lint/a11y/useSemanticElements: "This is a clickable item" role="button" style={{ height: 'var(--gantt-row-height)', }} tabIndex={0} > {/* <Checkbox onCheckedChange={handleCheck} className="shrink-0" /> */} <div className="pointer-events-none h-2 w-2 shrink-0 rounded-full" style={{ backgroundColor: feature.status.color, }} /> <p className="pointer-events-none flex-1 truncate text-left font-medium"> {feature.name} </p> <p className="pointer-events-none text-muted-foreground">{duration}</p> </div> );};export const GanttSidebarHeader: FC = () => ( <div className="sticky top-0 z-10 flex shrink-0 items-end justify-between gap-2.5 border-border/50 border-b bg-backdrop/90 p-2.5 font-medium text-muted-foreground text-xs backdrop-blur-sm" style={{ height: 'var(--gantt-header-height)' }} > {/* <Checkbox className="shrink-0" /> */} <p className="flex-1 truncate text-left">Issues</p> <p className="shrink-0">Duration</p> </div>);export type GanttSidebarGroupProps = { children: ReactNode; name: string; className?: string;};export const GanttSidebarGroup: FC<GanttSidebarGroupProps> = ({ children, name, className,}) => ( <div className={className}> <p className="w-full truncate p-2.5 text-left font-medium text-muted-foreground text-xs" style={{ height: 'var(--gantt-row-height)' }} > {name} </p> <div className="divide-y divide-border/50">{children}</div> </div>);export type GanttSidebarProps = { children: ReactNode; className?: string;};export const GanttSidebar: FC<GanttSidebarProps> = ({ children, className,}) => ( <div className={cn( 'sticky left-0 z-30 h-max min-h-full overflow-clip border-border/50 border-r bg-background/90 backdrop-blur-md', className )} data-roadmap-ui="gantt-sidebar" > <GanttSidebarHeader /> <div className="space-y-4">{children}</div> </div>);export type GanttAddFeatureHelperProps = { top: number; className?: string;};export const GanttAddFeatureHelper: FC<GanttAddFeatureHelperProps> = ({ top, className,}) => { const [scrollX] = useGanttScrollX(); const gantt = useContext(GanttContext); const [mousePosition, mouseRef] = useMouse<HTMLDivElement>(); const handleClick = () => { const ganttRect = gantt.ref?.current?.getBoundingClientRect(); const x = mousePosition.x - (ganttRect?.left ?? 0) + scrollX - gantt.sidebarWidth; const currentDate = getDateByMousePosition(gantt, x); gantt.onAddItem?.(currentDate); }; return ( <div className={cn('absolute top-0 w-full px-0.5', className)} ref={mouseRef} style={{ marginTop: -gantt.rowHeight / 2, transform: `translateY(${top}px)`, }} > <button className="flex h-full w-full items-center justify-center rounded-md border border-dashed p-2" onClick={handleClick} type="button" > <PlusIcon className="pointer-events-none select-none text-muted-foreground" size={16} /> </button> </div> );};export type GanttColumnProps = { index: number; isColumnSecondary?: (item: number) => boolean;};export const GanttColumn: FC<GanttColumnProps> = ({ index, isColumnSecondary,}) => { const gantt = useContext(GanttContext); const [dragging] = useGanttDragging(); const [mousePosition, mouseRef] = useMouse<HTMLDivElement>(); const [hovering, setHovering] = useState(false); const [windowScroll] = useWindowScroll(); const handleMouseEnter = () => setHovering(true); const handleMouseLeave = () => setHovering(false); const top = useThrottle( mousePosition.y - (mouseRef.current?.getBoundingClientRect().y ?? 0) - (windowScroll.y ?? 0), 10 ); return ( // biome-ignore lint/a11y/noStaticElementInteractions: "This is a clickable column" // biome-ignore lint/nursery/noNoninteractiveElementInteractions: "This is a clickable column" <div className={cn( 'group relative h-full overflow-hidden', isColumnSecondary?.(index) ? 'bg-secondary' : '' )} onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave} ref={mouseRef} > {!dragging && hovering && gantt.onAddItem ? ( <GanttAddFeatureHelper top={top} /> ) : null} </div> );};export type GanttColumnsProps = { columns: number; isColumnSecondary?: (item: number) => boolean;};export const GanttColumns: FC<GanttColumnsProps> = ({ columns, isColumnSecondary,}) => { const id = useId(); return ( <div className="divide grid h-full w-full divide-x divide-border/50" style={{ gridTemplateColumns: `repeat(${columns}, var(--gantt-column-width))`, }} > {Array.from({ length: columns }).map((_, index) => ( <GanttColumn index={index} isColumnSecondary={isColumnSecondary} key={`${id}-${index}`} /> ))} </div> );};export type GanttCreateMarkerTriggerProps = { onCreateMarker: (date: Date) => void; className?: string;};export const GanttCreateMarkerTrigger: FC<GanttCreateMarkerTriggerProps> = ({ onCreateMarker, className,}) => { const gantt = useContext(GanttContext); const [mousePosition, mouseRef] = useMouse<HTMLDivElement>(); const [windowScroll] = useWindowScroll(); const x = useThrottle( mousePosition.x - (mouseRef.current?.getBoundingClientRect().x ?? 0) - (windowScroll.x ?? 0), 10 ); const date = getDateByMousePosition(gantt, x); const handleClick = () => onCreateMarker(date); return ( <div className={cn( 'group pointer-events-none absolute top-0 left-0 h-full w-full select-none overflow-visible', className )} ref={mouseRef} > <div className="-ml-2 pointer-events-auto sticky top-6 z-20 flex w-4 flex-col items-center justify-center gap-1 overflow-visible opacity-0 group-hover:opacity-100" style={{ transform: `translateX(${x}px)` }} > <button className="z-50 inline-flex h-4 w-4 items-center justify-center rounded-full bg-card" onClick={handleClick} type="button" > <PlusIcon className="text-muted-foreground" size={12} /> </button> <div className="whitespace-nowrap rounded-full border border-border/50 bg-background/90 px-2 py-1 text-foreground text-xs backdrop-blur-lg"> {formatDate(date, 'MMM dd, yyyy')} </div> </div> </div> );};export type GanttFeatureDragHelperProps = { featureId: GanttFeature['id']; direction: 'left' | 'right'; date: Date | null;};export const GanttFeatureDragHelper: FC<GanttFeatureDragHelperProps> = ({ direction, featureId, date,}) => { const [, setDragging] = useGanttDragging(); const { attributes, listeners, setNodeRef } = useDraggable({ id: `feature-drag-helper-${featureId}`, }); const isPressed = Boolean(attributes['aria-pressed']); useEffect(() => setDragging(isPressed), [isPressed, setDragging]); return ( <div className={cn( 'group -translate-y-1/2 !cursor-col-resize absolute top-1/2 z-[3] h-full w-6 rounded-md outline-none', direction === 'left' ? '-left-2.5' : '-right-2.5' )} ref={setNodeRef} {...attributes} {...listeners} > <div className={cn( '-translate-y-1/2 absolute top-1/2 h-[80%] w-1 rounded-sm bg-muted-foreground opacity-0 transition-all', direction === 'left' ? 'left-2.5' : 'right-2.5', direction === 'left' ? 'group-hover:left-0' : 'group-hover:right-0', isPressed && (direction === 'left' ? 'left-0' : 'right-0'), 'group-hover:opacity-100', isPressed && 'opacity-100' )} /> {date && ( <div className={cn( '-translate-x-1/2 absolute top-10 hidden whitespace-nowrap rounded-lg border border-border/50 bg-background/90 px-2 py-1 text-foreground text-xs backdrop-blur-lg group-hover:block', isPressed && 'block' )} > {format(date, 'MMM dd, yyyy')} </div> )} </div> );};export type GanttFeatureItemCardProps = Pick<GanttFeature, 'id'> & { children?: ReactNode;};export const GanttFeatureItemCard: FC<GanttFeatureItemCardProps> = ({ id, children,}) => { const [, setDragging] = useGanttDragging(); const { attributes, listeners, setNodeRef } = useDraggable({ id }); const isPressed = Boolean(attributes['aria-pressed']); useEffect(() => setDragging(isPressed), [isPressed, setDragging]); return ( <Card className="h-full w-full rounded-md bg-background p-2 text-xs shadow-sm"> <div className={cn( 'flex h-full w-full items-center justify-between gap-2 text-left', isPressed && 'cursor-grabbing' )} {...attributes} {...listeners} ref={setNodeRef} > {children} </div> </Card> );};export type GanttFeatureItemProps = GanttFeature & { onMove?: (id: string, startDate: Date, endDate: Date | null) => void; children?: ReactNode; className?: string;};export const GanttFeatureItem: FC<GanttFeatureItemProps> = ({ onMove, children, className, ...feature}) => { const [scrollX] = useGanttScrollX(); const gantt = useContext(GanttContext); const timelineStartDate = useMemo( () => new Date(gantt.timelineData.at(0)?.year ?? 0, 0, 1), [gantt.timelineData] ); const [startAt, setStartAt] = useState<Date>(feature.startAt); const [endAt, setEndAt] = useState<Date | null>(feature.endAt); // Memoize expensive calculations const width = useMemo( () => getWidth(startAt, endAt, gantt), [startAt, endAt, gantt] ); const offset = useMemo( () => getOffset(startAt, timelineStartDate, gantt), [startAt, timelineStartDate, gantt] ); const addRange = useMemo(() => getAddRange(gantt.range), [gantt.range]); const [mousePosition] = useMouse<HTMLDivElement>(); const [previousMouseX, setPreviousMouseX] = useState(0); const [previousStartAt, setPreviousStartAt] = useState(startAt); const [previousEndAt, setPreviousEndAt] = useState(endAt); const mouseSensor = useSensor(MouseSensor, { activationConstraint: { distance: 10, }, }); const handleItemDragStart = useCallback(() => { setPreviousMouseX(mousePosition.x); setPreviousStartAt(startAt); setPreviousEndAt(endAt); }, [mousePosition.x, startAt, endAt]); const handleItemDragMove = useCallback(() => { const currentDate = getDateByMousePosition(gantt, mousePosition.x); const originalDate = getDateByMousePosition(gantt, previousMouseX); const delta = gantt.range === 'daily' ? getDifferenceIn(gantt.range)(currentDate, originalDate) : getInnerDifferenceIn(gantt.range)(currentDate, originalDate); const newStartDate = addDays(previousStartAt, delta); const newEndDate = previousEndAt ? addDays(previousEndAt, delta) : null; setStartAt(newStartDate); setEndAt(newEndDate); }, [gantt, mousePosition.x, previousMouseX, previousStartAt, previousEndAt]); const onDragEnd = useCallback( () => onMove?.(feature.id, startAt, endAt), [onMove, feature.id, startAt, endAt] ); const handleLeftDragMove = useCallback(() => { const ganttRect = gantt.ref?.current?.getBoundingClientRect(); const x = mousePosition.x - (ganttRect?.left ?? 0) + scrollX - gantt.sidebarWidth; const newStartAt = getDateByMousePosition(gantt, x); setStartAt(newStartAt); }, [gantt, mousePosition.x, scrollX]); const handleRightDragMove = useCallback(() => { const ganttRect = gantt.ref?.current?.getBoundingClientRect(); const x = mousePosition.x - (ganttRect?.left ?? 0) + scrollX - gantt.sidebarWidth; const newEndAt = getDateByMousePosition(gantt, x); setEndAt(newEndAt); }, [gantt, mousePosition.x, scrollX]); return ( <div className={cn('relative flex w-max min-w-full py-0.5', className)} style={{ height: 'var(--gantt-row-height)' }} > <div className="pointer-events-auto absolute top-0.5" style={{ height: 'calc(var(--gantt-row-height) - 4px)', width: Math.round(width), left: Math.round(offset), }} > {onMove && ( <DndContext modifiers={[restrictToHorizontalAxis]} onDragEnd={onDragEnd} onDragMove={handleLeftDragMove} sensors={[mouseSensor]} > <GanttFeatureDragHelper date={startAt} direction="left" featureId={feature.id} /> </DndContext> )} <DndContext modifiers={[restrictToHorizontalAxis]} onDragEnd={onDragEnd} onDragMove={handleItemDragMove} onDragStart={handleItemDragStart} sensors={[mouseSensor]} > <GanttFeatureItemCard id={feature.id}> {children ?? ( <p className="flex-1 truncate text-xs">{feature.name}</p> )} </GanttFeatureItemCard> </DndContext> {onMove && ( <DndContext modifiers={[restrictToHorizontalAxis]} onDragEnd={onDragEnd} onDragMove={handleRightDragMove} sensors={[mouseSensor]} > <GanttFeatureDragHelper date={endAt ?? addRange(startAt, 2)} direction="right" featureId={feature.id} /> </DndContext> )} </div> </div> );};export type GanttFeatureListGroupProps = { children: ReactNode; className?: string;};export const GanttFeatureListGroup: FC<GanttFeatureListGroupProps> = ({ children, className,}) => ( <div className={className} style={{ paddingTop: 'var(--gantt-row-height)' }}> {children} </div>);export type GanttFeatureRowProps = { features: GanttFeature[]; onMove?: (id: string, startAt: Date, endAt: Date | null) => void; children?: (feature: GanttFeature) => ReactNode; className?: string;};export const GanttFeatureRow: FC<GanttFeatureRowProps> = ({ features, onMove, children, className,}) => { // Sort features by start date to handle potential overlaps const sortedFeatures = [...features].sort((a, b) => a.startAt.getTime() - b.startAt.getTime() ); // Calculate sub-row positions for overlapping features using a proper algorithm const featureWithPositions = []; const subRowEndTimes: Date[] = []; // Track when each sub-row becomes free for (const feature of sortedFeatures) { let subRow = 0; // Find the first sub-row that's free (doesn't overlap) while (subRow < subRowEndTimes.length && subRowEndTimes[subRow] > feature.startAt) { subRow++; } // Update the end time for this sub-row if (subRow === subRowEndTimes.length) { subRowEndTimes.push(feature.endAt); } else { subRowEndTimes[subRow] = feature.endAt; } featureWithPositions.push({ ...feature, subRow }); } const maxSubRows = Math.max(1, subRowEndTimes.length); const subRowHeight = 36; // Base row height return ( <div className={cn('relative', className)} style={{ height: `${maxSubRows * subRowHeight}px`, minHeight: 'var(--gantt-row-height)' }} > {featureWithPositions.map((feature) => ( <div key={feature.id} className="absolute w-full" style={{ top: `${feature.subRow * subRowHeight}px`, height: `${subRowHeight}px` }} > <GanttFeatureItem {...feature} onMove={onMove} > {children ? children(feature) : ( <p className="flex-1 truncate text-xs">{feature.name}</p> )} </GanttFeatureItem> </div> ))} </div> );};export type GanttFeatureListProps = { className?: string; children: ReactNode;};export const GanttFeatureList: FC<GanttFeatureListProps> = ({ className, children,}) => ( <div className={cn('absolute top-0 left-0 h-full w-max space-y-4', className)} style={{ marginTop: 'var(--gantt-header-height)' }} > {children} </div>);export const GanttMarker: FC< GanttMarkerProps & { onRemove?: (id: string) => void; className?: string; }> = memo(({ label, date, id, onRemove, className }) => { const gantt = useContext(GanttContext); const differenceIn = useMemo( () => getDifferenceIn(gantt.range), [gantt.range] ); const timelineStartDate = useMemo( () => new Date(gantt.timelineData.at(0)?.year ?? 0, 0, 1), [gantt.timelineData] ); // Memoize expensive calculations const offset = useMemo( () => differenceIn(date, timelineStartDate), [differenceIn, date, timelineStartDate] ); const innerOffset = useMemo( () => calculateInnerOffset( date, gantt.range, (gantt.columnWidth * gantt.zoom) / 100 ), [date, gantt.range, gantt.columnWidth, gantt.zoom] ); const handleRemove = useCallback(() => onRemove?.(id), [onRemove, id]); return ( <div className="pointer-events-none absolute top-0 left-0 z-20 flex h-full select-none flex-col items-center justify-center overflow-visible" style={{ width: 0, transform: `translateX(calc(var(--gantt-column-width) * ${offset} + ${innerOffset}px))`, }} > <ContextMenu> <ContextMenuTrigger asChild> <div className={cn( 'group pointer-events-auto sticky top-0 flex select-auto flex-col flex-nowrap items-center justify-center whitespace-nowrap rounded-b-md bg-card px-2 py-1 text-foreground text-xs', className )} > {label} <span className="max-h-[0] overflow-hidden opacity-80 transition-all group-hover:max-h-[2rem]"> {formatDate(date, 'MMM dd, yyyy')} </span> </div> </ContextMenuTrigger> <ContextMenuContent> {onRemove ? ( <ContextMenuItem className="flex items-center gap-2 text-destructive" onClick={handleRemove} > <TrashIcon size={16} /> Remove marker </ContextMenuItem> ) : null} </ContextMenuContent> </ContextMenu> <div className={cn('h-full w-px bg-card', className)} /> </div> );});GanttMarker.displayName = 'GanttMarker';export type GanttProviderProps = { range?: Range; zoom?: number; onAddItem?: (date: Date) => void; children: ReactNode; className?: string;};export const GanttProvider: FC<GanttProviderProps> = ({ zoom = 100, range = 'monthly', onAddItem, children, className,}) => { const scrollRef = useRef<HTMLDivElement>(null); const [timelineData, setTimelineData] = useState<TimelineData>( createInitialTimelineData(new Date()) ); const [, setScrollX] = useGanttScrollX(); const [sidebarWidth, setSidebarWidth] = useState(0); const headerHeight = 60; const rowHeight = 36; let columnWidth = 50; if (range === 'monthly') { columnWidth = 150; } else if (range === 'quarterly') { columnWidth = 100; } // Memoize CSS variables to prevent unnecessary re-renders const cssVariables = useMemo( () => ({ '--gantt-zoom': `${zoom}`, '--gantt-column-width': `${(zoom / 100) * columnWidth}px`, '--gantt-header-height': `${headerHeight}px`, '--gantt-row-height': `${rowHeight}px`, '--gantt-sidebar-width': `${sidebarWidth}px`, }) as CSSProperties, [zoom, columnWidth, sidebarWidth] ); useEffect(() => { if (scrollRef.current) { scrollRef.current.scrollLeft = scrollRef.current.scrollWidth / 2 - scrollRef.current.clientWidth / 2; setScrollX(scrollRef.current.scrollLeft); } }, [setScrollX]); // Update sidebar width when DOM is ready useEffect(() => { const updateSidebarWidth = () => { const sidebarElement = scrollRef.current?.querySelector( '[data-roadmap-ui="gantt-sidebar"]' ); const newWidth = sidebarElement ? 300 : 0; setSidebarWidth(newWidth); }; // Update immediately updateSidebarWidth(); // Also update on resize or when children change const observer = new MutationObserver(updateSidebarWidth); if (scrollRef.current) { observer.observe(scrollRef.current, { childList: true, subtree: true, }); } return () => { observer.disconnect(); }; }, []); // Fix the useCallback to include all dependencies const handleScroll = useCallback( throttle(() => { const scrollElement = scrollRef.current; if (!scrollElement) { return; } const { scrollLeft, scrollWidth, clientWidth } = scrollElement; setScrollX(scrollLeft); if (scrollLeft === 0) { // Extend timelineData to the past const firstYear = timelineData[0]?.year; if (!firstYear) { return; } const newTimelineData: TimelineData = [...timelineData]; newTimelineData.unshift({ year: firstYear - 1, quarters: new Array(4).fill(null).map((_, quarterIndex) => ({ months: new Array(3).fill(null).map((_, monthIndex) => { const month = quarterIndex * 3 + monthIndex; return { days: getDaysInMonth(new Date(firstYear, month, 1)), }; }), })), }); setTimelineData(newTimelineData); // Scroll a bit forward so it's not at the very start scrollElement.scrollLeft = scrollElement.clientWidth; setScrollX(scrollElement.scrollLeft); } else if (scrollLeft + clientWidth >= scrollWidth) { // Extend timelineData to the future const lastYear = timelineData.at(-1)?.year; if (!lastYear) { return; } const newTimelineData: TimelineData = [...timelineData]; newTimelineData.push({ year: lastYear + 1, quarters: new Array(4).fill(null).map((_, quarterIndex) => ({ months: new Array(3).fill(null).map((_, monthIndex) => { const month = quarterIndex * 3 + monthIndex; return { days: getDaysInMonth(new Date(lastYear, month, 1)), }; }), })), }); setTimelineData(newTimelineData); // Scroll a bit back so it's not at the very end scrollElement.scrollLeft = scrollElement.scrollWidth - scrollElement.clientWidth; setScrollX(scrollElement.scrollLeft); } }, 100), [] ); useEffect(() => { const scrollElement = scrollRef.current; if (scrollElement) { scrollElement.addEventListener('scroll', handleScroll); } return () => { // Fix memory leak by properly referencing the scroll element if (scrollElement) { scrollElement.removeEventListener('scroll', handleScroll); } }; }, [handleScroll]); const scrollToFeature = useCallback((feature: GanttFeature) => { const scrollElement = scrollRef.current; if (!scrollElement) { return; } // Calculate timeline start date from timelineData const timelineStartDate = new Date(timelineData[0].year, 0, 1); // Calculate the horizontal offset for the feature's start date const offset = getOffset(feature.startAt, timelineStartDate, { zoom, range, columnWidth, sidebarWidth, headerHeight, rowHeight, onAddItem, placeholderLength: 2, timelineData, ref: scrollRef, }); // Scroll to align the feature's start with the right side of the sidebar const targetScrollLeft = Math.max(0, offset); scrollElement.scrollTo({ left: targetScrollLeft, behavior: 'smooth', }); }, [timelineData, zoom, range, columnWidth, sidebarWidth, headerHeight, rowHeight, onAddItem]); return ( <GanttContext.Provider value={{ zoom, range, headerHeight, columnWidth, sidebarWidth, rowHeight, onAddItem, timelineData, placeholderLength: 2, ref: scrollRef, scrollToFeature, }} > <div className={cn( 'gantt relative grid h-full w-full flex-none select-none overflow-auto rounded-sm bg-secondary', range, className )} ref={scrollRef} style={{ ...cssVariables, gridTemplateColumns: 'var(--gantt-sidebar-width) 1fr', }} > {children} </div> </GanttContext.Provider> );};export type GanttTimelineProps = { children: ReactNode; className?: string;};export const GanttTimeline: FC<GanttTimelineProps> = ({ children, className,}) => ( <div className={cn( 'relative flex h-full w-max flex-none overflow-clip', className )} > {children} </div>);export type GanttTodayProps = { className?: string;};export const GanttToday: FC<GanttTodayProps> = ({ className }) => { const label = 'Today'; const date = useMemo(() => new Date(), []); const gantt = useContext(GanttContext); const differenceIn = useMemo( () => getDifferenceIn(gantt.range), [gantt.range] ); const timelineStartDate = useMemo( () => new Date(gantt.timelineData.at(0)?.year ?? 0, 0, 1), [gantt.timelineData] ); // Memoize expensive calculations const offset = useMemo( () => differenceIn(date, timelineStartDate), [differenceIn, date, timelineStartDate] ); const innerOffset = useMemo( () => calculateInnerOffset( date, gantt.range, (gantt.columnWidth * gantt.zoom) / 100 ), [date, gantt.range, gantt.columnWidth, gantt.zoom] ); return ( <div className="pointer-events-none absolute top-0 left-0 z-20 flex h-full select-none flex-col items-center justify-center overflow-visible" style={{ width: 0, transform: `translateX(calc(var(--gantt-column-width) * ${offset} + ${innerOffset}px))`, }} > <div className={cn( 'group pointer-events-auto sticky top-0 flex select-auto flex-col flex-nowrap items-center justify-center whitespace-nowrap rounded-b-md bg-card px-2 py-1 text-foreground text-xs', className )} > {label} <span className="max-h-[0] overflow-hidden opacity-80 transition-all group-hover:max-h-[2rem]"> {formatDate(date, 'MMM dd, yyyy')} </span> </div> <div className={cn('h-full w-px bg-card', className)} /> </div> );};
Use Cases
This free open source React component works well for:
- Construction and manufacturing - Multi-phase project tracking, equipment schedules, and delivery timelines with TypeScript safety
- Software development teams - Sprint visualization, release planning, and feature development using Next.js performance optimization
- Event coordination - Vendor timeline management, venue bookings, and setup schedules with shadcn/ui design consistency
- Hotel and hospitality - Room reservations, maintenance windows, and staff scheduling in unified JavaScript applications
- Educational institutions - Course planning, facility booking, and academic calendar management with drag-and-drop functionality
- Creative agencies - Campaign timelines, client deliverables, and resource allocation across React-based projects
Implementation Notes
- Date-fns integration providing bulletproof date arithmetic for time zones, leap years, and business days in React applications
- DND Kit foundation delivering drag-and-drop with collision detection and horizontal-only movement for clean timelines
- Jotai state management handling efficient state updates with custom throttling for smooth interactions across hundreds of tasks
- TypeScript consistency ensuring data integrity and type safety throughout complex Gantt chart operations
- Tailwind CSS utilities enabling easy customization with dark mode, custom colors, and responsive breakpoints for Next.js projects
- Performance optimization with efficient rendering and strategic memoization for enterprise-scale JavaScript applications
Accessibility
- Complete keyboard navigation supporting all timeline interactions with logical focus management
- Screen reader compatibility featuring meaningful ARIA labels and announcements for Gantt chart operations
- WCAG compliance ensuring React components meet accessibility standards while maintaining rich interactivity
- High contrast support with focus indicators and assistive technology compatibility for shadcn/ui design systems
- Universal usability making complex project management accessible to all users regardless of abilities
Snippet
Tabbed code display with one-click clipboard functionality and customizable content. Perfect for React documentation requiring code examples with Next.js integration and TypeScript support.
Kanban
Agile workflow visualization that actually works. Move tasks between columns with smooth animations, built for React teams using Next.js who value clean code and great user experiences.