Gantt

free

Gantt plots tasks, milestones, and dependencies across a timeline, with drag-to-reschedule, resize-to-adjust-duration, and drag-to-set-progress built in.

dayjs
Preview
Dark
import { useState } from 'react';
import Gantt from '@/components/composites/Gantt';
import type { Task } from '@/components/composites/Gantt';

const now = new Date();
const on = (day: number) => new Date(now.getFullYear(), now.getMonth(), day);

const initialTasks: Task[] = [
  {
    id: 'discovery',
    name: 'Discovery',
    type: 'task',
    start: on(1),
    end: on(5),
    progress: 100,
    displayOrder: 1,
  },
  {
    id: 'design',
    name: 'Design',
    type: 'task',
    start: on(5),
    end: on(11),
    progress: 80,
    dependencies: ['discovery'],
    displayOrder: 2,
  },
  {
    id: 'build',
    name: 'Build',
    type: 'task',
    start: on(11),
    end: on(20),
    progress: 45,
    dependencies: ['design'],
    displayOrder: 3,
  },
  {
    id: 'qa',
    name: 'QA & polish',
    type: 'task',
    start: on(18),
    end: on(25),
    progress: 15,
    dependencies: ['build'],
    displayOrder: 4,
  },
  {
    id: 'launch',
    name: 'Launch',
    type: 'milestone',
    start: on(27),
    end: on(27),
    progress: 0,
    dependencies: ['qa'],
    displayOrder: 5,
  },
];

export default function UsageDemo() {
  const [tasks, setTasks] = useState(initialTasks);

  const update = (task: Task) =>
    setTasks((prev) =>
      prev.map((t) =>
        t.id === task.id
          ? { ...t, start: task.start, end: task.end, progress: task.progress }
          : t,
      ),
    );

  return (
    <div className="w-full">
      <Gantt
        viewMode="Week"
        tasks={tasks}
        onDateChange={update}
        onProgressChange={update}
      />
    </div>
  );
}

Installation

Add this component with the NateUI CLI.

npx nateui@latest add Gantt

Examples

Every example below shares the same chart interactions: hold Space and drag to pan, hover a row to highlight it in both the table and the chart, and scrollbars stay hidden until the pointer is over the chart.

View Modes

Preview
Dark
import { useState } from 'react';
import Gantt from '@/components/composites/Gantt';
import Segment from '@/components/ui/Segment';
import type { Task } from '@/components/composites/Gantt';

const now = new Date();
const on = (day: number) => new Date(now.getFullYear(), now.getMonth(), day);

const tasks: Task[] = [
  {
    id: 'plan',
    name: 'Planning',
    type: 'task',
    start: on(2),
    end: on(8),
    progress: 100,
    displayOrder: 1,
  },
  {
    id: 'build',
    name: 'Build',
    type: 'task',
    start: on(8),
    end: on(20),
    progress: 60,
    dependencies: ['plan'],
    displayOrder: 2,
  },
  {
    id: 'ship',
    name: 'Ship',
    type: 'milestone',
    start: on(22),
    end: on(22),
    progress: 0,
    dependencies: ['build'],
    displayOrder: 3,
  },
];

const views = ['Day', 'Week', 'Month', 'Year'] as const;

export default function ViewModesDemo() {
  const [view, setView] = useState<(typeof views)[number]>('Week');

  return (
    <div className="w-full">
      <div className="p-4 flex justify-end border-b">
        <Segment
          value={view}
          onChange={(value) => value && setView(value as typeof view)}
        >
          {views.map((option) => (
            <Segment.Item key={option} value={option}>
              {option}
            </Segment.Item>
          ))}
        </Segment>
      </div>
      <Gantt viewMode={view} tasks={tasks} />
    </div>
  );
}

Off Days

Preview
Dark
import { useState } from 'react';
import Gantt from '@/components/composites/Gantt';
import type { Task } from '@/components/composites/Gantt';

const now = new Date();
const on = (day: number) => new Date(now.getFullYear(), now.getMonth(), day);

const tasks: Task[] = [
  {
    id: 'design',
    name: 'Design',
    type: 'task',
    start: on(1),
    end: on(8),
    progress: 100,
    displayOrder: 1,
  },
  {
    id: 'build',
    name: 'Build',
    type: 'task',
    start: on(8),
    end: on(18),
    progress: 55,
    dependencies: ['design'],
    displayOrder: 2,
  },
  {
    id: 'launch',
    name: 'Launch',
    type: 'milestone',
    start: on(20),
    end: on(20),
    progress: 0,
    dependencies: ['build'],
    displayOrder: 3,
  },
];

export default function OffDaysDemo() {
  const [items] = useState(tasks);

  return (
    <div className="w-full">
      <Gantt viewMode="Day" tasks={items} offDays={[5, 6]} />
    </div>
  );
}

Grouped Tasks

Preview
Dark
import { useState } from 'react';
import Gantt from '@/components/composites/Gantt';
import type { Task } from '@/components/composites/Gantt';

const now = new Date();
const on = (day: number) => new Date(now.getFullYear(), now.getMonth(), day);

const initialTasks: Task[] = [
  {
    id: 'phase-1',
    name: 'Phase 1 · Foundations',
    type: 'project',
    start: on(1),
    end: on(12),
    progress: 70,
    hideChildren: false,
    displayOrder: 1,
  },
  {
    id: 'data-model',
    name: 'Data model',
    type: 'task',
    project: 'phase-1',
    start: on(1),
    end: on(5),
    progress: 100,
    displayOrder: 2,
  },
  {
    id: 'auth',
    name: 'Authentication',
    type: 'task',
    project: 'phase-1',
    start: on(5),
    end: on(12),
    progress: 60,
    dependencies: ['data-model'],
    displayOrder: 3,
  },
  {
    id: 'phase-2',
    name: 'Phase 2 · Product',
    type: 'project',
    start: on(12),
    end: on(26),
    progress: 20,
    hideChildren: false,
    displayOrder: 4,
  },
  {
    id: 'dashboard',
    name: 'Dashboard',
    type: 'task',
    project: 'phase-2',
    start: on(12),
    end: on(20),
    progress: 30,
    displayOrder: 5,
  },
  {
    id: 'reports',
    name: 'Reports',
    type: 'task',
    project: 'phase-2',
    start: on(20),
    end: on(26),
    progress: 0,
    dependencies: ['dashboard'],
    displayOrder: 6,
  },
];

export default function GroupedTasksDemo() {
  const [tasks, setTasks] = useState(initialTasks);

  const handleExpanderClick = (task: Task) =>
    setTasks((prev) =>
      prev.map((t) =>
        t.id === task.id ? { ...t, hideChildren: task.hideChildren } : t,
      ),
    );

  return (
    <div className="w-full">
      <Gantt
        viewMode="Week"
        tasks={tasks}
        onExpanderClick={handleExpanderClick}
      />
    </div>
  );
}

Add & Edit Tasks

onCellClick fires with the row's task and the clicked date when an empty timeline cell is clicked; onDoubleClick fires with the task when a bar is double-clicked. Neither prop renders any UI on its own — wire them to open whatever your app needs, like the dialog below.

Preview
Dark
import { useState } from 'react';
import Button from '@/components/ui/Button';
import DatePicker from '@/components/ui/DatePicker';
import Dialog from '@/components/ui/Dialog';
import Form from '@/components/ui/Form';
import Input from '@/components/ui/Input';
import Slider from '@/components/ui/Slider';
import Gantt from '@/components/composites/Gantt';
import type { Task } from '@/components/composites/Gantt';

const now = new Date();
const on = (day: number) => new Date(now.getFullYear(), now.getMonth(), day);
const addDays = (date: Date, days: number) => {
  const next = new Date(date);
  next.setDate(next.getDate() + days);
  return next;
};

const initialTasks: Task[] = [
  {
    id: 'kickoff',
    name: 'Kickoff',
    type: 'task',
    start: on(1),
    end: on(4),
    progress: 100,
    displayOrder: 1,
  },
  {
    id: 'design',
    name: 'Design',
    type: 'task',
    start: on(4),
    end: on(11),
    progress: 80,
    dependencies: ['kickoff'],
    displayOrder: 2,
  },
  {
    id: 'build',
    name: 'Build',
    type: 'task',
    start: on(11),
    end: on(22),
    progress: 45,
    dependencies: ['design'],
    displayOrder: 3,
  },
  {
    id: 'qa',
    name: 'QA & polish',
    type: 'task',
    start: on(20),
    end: on(27),
    progress: 15,
    dependencies: ['build'],
    displayOrder: 4,
  },
  {
    id: 'beta',
    name: 'Beta rollout',
    type: 'task',
    start: on(27),
    end: on(33),
    progress: 0,
    dependencies: ['qa'],
    displayOrder: 5,
  },
  {
    id: 'launch',
    name: 'Launch',
    type: 'milestone',
    start: on(35),
    end: on(35),
    progress: 0,
    dependencies: ['beta'],
    displayOrder: 6,
  },
];

type DialogState =
  | { mode: 'create'; date: Date }
  | { mode: 'edit'; task: Task };

type TaskFormValues = { name: string; start: Date; end: Date; progress: number };

function TaskDialogForm({
  state,
  onCancel,
  onSubmit,
  onDelete,
}: {
  state: DialogState;
  onCancel: () => void;
  onSubmit: (values: TaskFormValues) => void;
  onDelete: () => void;
}) {
  const initial: TaskFormValues =
    state.mode === 'edit'
      ? {
          name: state.task.name,
          start: state.task.start,
          end: state.task.end,
          progress: state.task.progress,
        }
      : { name: '', start: state.date, end: addDays(state.date, 3), progress: 0 };

  const [name, setName] = useState(initial.name);
  const [start, setStart] = useState<Date | null>(initial.start);
  const [end, setEnd] = useState<Date | null>(initial.end);
  const [progress, setProgress] = useState(initial.progress);

  return (
    <Form
      onSubmit={(event) => {
        event.preventDefault();
        if (!start || !end) return;
        onSubmit({ name, start, end, progress });
      }}
    >
      <div className="border-b px-4 py-4">
        <h5 className="text-lg font-semibold text-foreground">
          {state.mode === 'create' ? 'Add task' : 'Edit task'}
        </h5>
      </div>

      <div className="space-y-4 p-4">
        <Form.Field label="Name" htmlFor="gantt-dialog-name" asterisk>
          <Input
            id="gantt-dialog-name"
            required
            value={name}
            onChange={(event) => setName(event.target.value)}
            placeholder="Task name"
          />
        </Form.Field>

        <div className="grid grid-cols-2 gap-4">
          <Form.Field label="Start" htmlFor="gantt-dialog-start">
            <DatePicker value={start} onChange={setStart} />
          </Form.Field>
          <Form.Field label="End" htmlFor="gantt-dialog-end">
            <DatePicker value={end} onChange={setEnd} />
          </Form.Field>
        </div>

        <Form.Field label={`Progress — ${progress}%`} htmlFor="gantt-dialog-progress">
          <Slider value={progress} onChange={setProgress} />
        </Form.Field>
      </div>

      <div className="flex items-center justify-end gap-2 px-4 py-3">
        {state.mode === 'edit' && (
          <Button type="button" destructive onClick={onDelete} className="mr-auto">
            Delete
          </Button>
        )}
        <Button type="button" onClick={onCancel}>
          Cancel
        </Button>
        <Button type="submit" variant="solid">
          {state.mode === 'create' ? 'Add' : 'Save'}
        </Button>
      </div>
    </Form>
  );
}

export default function AddEditTasksDemo() {
  const [tasks, setTasks] = useState(initialTasks);
  const [dialogState, setDialogState] = useState<DialogState | null>(null);

  const closeDialog = () => setDialogState(null);

  return (
    <div className="w-full">
      <Gantt
        viewMode="Week"
        tasks={tasks}
        onCellClick={(_task, date) => setDialogState({ mode: 'create', date })}
        onDoubleClick={(task) => setDialogState({ mode: 'edit', task })}
      />

      <Dialog
        isOpen={dialogState !== null}
        onClose={closeDialog}
        className="p-0"
      >
        {dialogState && (
          <TaskDialogForm
            key={dialogState.mode === 'edit' ? dialogState.task.id : 'create'}
            state={dialogState}
            onCancel={closeDialog}
            onSubmit={(values) => {
              if (dialogState.mode === 'create') {
                setTasks((prev) => [
                  ...prev,
                  {
                    id: `task-${Date.now()}`,
                    type: 'task',
                    displayOrder: prev.length + 1,
                    ...values,
                  },
                ]);
              } else {
                const editing = dialogState.task;
                setTasks((prev) =>
                  prev.map((t) => (t.id === editing.id ? { ...t, ...values } : t)),
                );
              }
              closeDialog();
            }}
            onDelete={() => {
              if (dialogState.mode !== 'edit') return;
              const editing = dialogState.task;
              setTasks((prev) => prev.filter((t) => t.id !== editing.id));
              closeDialog();
            }}
          />
        )}
      </Dialog>
    </div>
  );
}

Resizable Bars

Preview
Dark
import { useState } from 'react';
import Gantt from '@/components/composites/Gantt';
import type { Task } from '@/components/composites/Gantt';

const now = new Date();
const on = (day: number) => new Date(now.getFullYear(), now.getMonth(), day);
const formatDate = new Intl.DateTimeFormat(undefined, { month: 'short', day: 'numeric' });

const initialTasks: Task[] = [
  {
    id: 'draft',
    name: 'Draft',
    type: 'task',
    start: on(1),
    end: on(7),
    progress: 100,
    displayOrder: 1,
  },
  {
    id: 'review',
    name: 'Review',
    type: 'task',
    start: on(7),
    end: on(13),
    progress: 55,
    dependencies: ['draft'],
    displayOrder: 2,
  },
  {
    id: 'approve',
    name: 'Approve',
    type: 'task',
    start: on(13),
    end: on(17),
    progress: 20,
    dependencies: ['review'],
    displayOrder: 3,
  },
  {
    id: 'publish',
    name: 'Publish',
    type: 'task',
    start: on(17),
    end: on(21),
    progress: 0,
    dependencies: ['approve'],
    displayOrder: 4,
  },
];

export default function ResizableBarsDemo() {
  const [tasks, setTasks] = useState(initialTasks);

  const update = (task: Task) =>
    setTasks((prev) =>
      prev.map((t) =>
        t.id === task.id
          ? { ...t, start: task.start, end: task.end, progress: task.progress }
          : t,
      ),
    );

  return (
    <Gantt
      viewMode="Week"
      tasks={tasks}
      onDateChange={update}
      onProgressChange={update}
    />
  );
}

Resizable Task List

Preview
Dark
import { useState } from 'react';
import Gantt from '@/components/composites/Gantt';
import Avatar from '@/components/ui/Avatar';
import type { Task } from '@/components/composites/Gantt';

const now = new Date();
const on = (day: number) => new Date(now.getFullYear(), now.getMonth(), day);

type TaskMeta = {
  owner: string;
  ownerImage: string;
};

const initialTasks: Task<TaskMeta>[] = [
  {
    id: 'research',
    name: 'User research',
    type: 'task',
    start: on(1),
    end: on(6),
    progress: 100,
    owner: 'Mira Okafor',
    ownerImage: '/img/avatars/thumb-11.jpg',
    displayOrder: 1,
  },
  {
    id: 'flows',
    name: 'Flow design',
    type: 'task',
    start: on(6),
    end: on(13),
    progress: 70,
    owner: 'Dan Levy',
    ownerImage: '/img/avatars/thumb-12.jpg',
    dependencies: ['research'],
    displayOrder: 2,
  },
  {
    id: 'build',
    name: 'Implementation',
    type: 'task',
    start: on(13),
    end: on(22),
    progress: 30,
    owner: 'Priya Shah',
    ownerImage: '/img/avatars/thumb-13.jpg',
    dependencies: ['flows'],
    displayOrder: 3,
  },
  {
    id: 'ship',
    name: 'Ship v2',
    type: 'milestone',
    start: on(24),
    end: on(24),
    progress: 0,
    owner: 'Team',
    ownerImage: '/img/avatars/thumb-14.jpg',
    dependencies: ['build'],
    displayOrder: 4,
  },
];

export default function ResizableTaskListDemo() {
  const [tasks] = useState(initialTasks);

  return (
    <div className="w-full">
      <Gantt<TaskMeta>
        viewMode="Week"
        tasks={tasks}
        defaultTaskListWidth={280}
        taskListMinWidth={160}
        taskListMaxWidth={480}
        columns={[
          {
            header: 'Task',
            width: 180,
            cell: (task) => (
              <div className="flex h-full items-center px-4">
                <span className="truncate font-medium">{task.name}</span>
              </div>
            ),
          },
          {
            header: 'Owner',
            width: 150,
            cell: (task) => (
              <div className="flex h-full items-center gap-2 px-4">
                <Avatar
                  size={22}
                  src={task.ownerImage}
                  alt={task.owner}
                  className="text-xs"
                >
                  {task.owner.charAt(0)}
                </Avatar>
                <span className="truncate">
                  {task.owner}
                </span>
              </div>
            ),
          },
          {
            header: 'Duration',
            width: 100,
            cell: (task) => {
              const days = Math.round(
                (Number(task.end) - Number(task.start)) / 86_400_000,
              );
              return (
                <div className="flex h-full items-center px-4 text-muted-foreground tabular-nums">
                  {days > 0 ? `${days}d` : '—'}
                </div>
              );
            },
          },
        ]}
      />
    </div>
  );
}

Custom Columns

Preview
Dark
import { useState } from 'react';
import Gantt from '@/components/composites/Gantt';
import Avatar from '@/components/ui/Avatar';
import type { Task } from '@/components/composites/Gantt';

const now = new Date();
const on = (day: number) => new Date(now.getFullYear(), now.getMonth(), day);

type TaskMeta = {
  owner: string;
  ownerImage: string;
};

const initialTasks: Task<TaskMeta>[] = [
  {
    id: 'research',
    name: 'User research',
    type: 'task',
    start: on(1),
    end: on(6),
    progress: 100,
    owner: 'Mira Okafor',
    ownerImage: '/img/avatars/thumb-11.jpg',
    displayOrder: 1,
  },
  {
    id: 'flows',
    name: 'Flow design',
    type: 'task',
    start: on(6),
    end: on(13),
    progress: 70,
    owner: 'Dan Levy',
    ownerImage: '/img/avatars/thumb-12.jpg',
    dependencies: ['research'],
    displayOrder: 2,
  },
  {
    id: 'build',
    name: 'Implementation',
    type: 'task',
    start: on(13),
    end: on(22),
    progress: 30,
    owner: 'Priya Shah',
    ownerImage: '/img/avatars/thumb-13.jpg',
    dependencies: ['flows'],
    displayOrder: 3,
  },
  {
    id: 'ship',
    name: 'Ship v2',
    type: 'milestone',
    start: on(24),
    end: on(24),
    progress: 0,
    owner: 'Team',
    ownerImage: '/img/avatars/thumb-14.jpg',
    dependencies: ['build'],
    displayOrder: 4,
  },
];

export default function CustomColumnsDemo() {
  const [tasks] = useState(initialTasks);

  return (
    <div className="w-full">
      <Gantt<TaskMeta>
        viewMode="Week"
        tasks={tasks}
        columns={[
          {
            header: 'Task',
            width: 180,
            cell: (task) => (
              <div className="flex h-full items-center px-4">
                <span className="truncate font-medium">{task.name}</span>
              </div>
            ),
          },
          {
            header: 'Owner',
            width: 150,
            cell: (task) => (
              <div className="flex h-full items-center gap-2 px-4">
                <Avatar
                  size={22}
                  src={task.ownerImage}
                  alt={task.owner}
                  className="text-xs"
                >
                  {task.owner.charAt(0)}
                </Avatar>
                <span className="truncate">
                  {task.owner}
                </span>
              </div>
            ),
          },
          {
            header: 'Duration',
            width: 100,
            cell: (task) => {
              const days = Math.round(
                (Number(task.end) - Number(task.start)) / 86_400_000,
              );
              return (
                <div className="flex h-full items-center px-4 text-muted-foreground tabular-nums">
                  {days > 0 ? `${days}d` : '—'}
                </div>
              );
            },
          },
        ]}
      />
    </div>
  );
}

Custom Bar Content

Preview
Dark
import { useState } from 'react';
import Gantt from '@/components/composites/Gantt';
import type { Task } from '@/components/composites/Gantt';

const now = new Date();
const on = (day: number) => new Date(now.getFullYear(), now.getMonth(), day);

type TaskMeta = {
  styles: {
    indicatorColor?: string;
    progressClass?: string;
    wrapperClass?: string;
  };
};

const tasks: Task<TaskMeta>[] = [
  {
    id: 'marketing',
    name: 'Marketing Revamp',
    type: 'project',
    start: on(1),
    end: on(7),
    progress: 78,
    displayOrder: 1,
    hideChildren: false,
    styles: {
      progressClass: 'fill-palette-cyan',
      wrapperClass: 'fill-muted',
    },
  },
  {
    id: 'meeting-brief',
    name: 'Project Brief',
    type: 'task',
    start: on(1),
    end: on(3),
    progress: 100,
    project: 'marketing',
    displayOrder: 2,
    styles: { indicatorColor: 'var(--color-palette-cyan)' },
  },
  {
    id: 'research',
    name: 'Content Research',
    type: 'task',
    start: on(2),
    end: on(6),
    progress: 55,
    project: 'marketing',
    displayOrder: 3,
    styles: { indicatorColor: 'var(--color-palette-cyan)' },
  },
  {
    id: 'commerce',
    name: 'Commerce Revamp',
    type: 'project',
    start: on(3),
    end: on(9),
    progress: 66,
    displayOrder: 4,
    hideChildren: false,
    styles: {
      progressClass: 'fill-palette-purple',
      wrapperClass: 'fill-muted',
    },
  },
  {
    id: 'internal-meeting',
    name: 'Team Sync',
    type: 'task',
    start: on(3),
    end: on(5),
    progress: 100,
    project: 'commerce',
    displayOrder: 5,
    styles: { indicatorColor: 'var(--color-palette-purple)' },
  },
  {
    id: 'review',
    name: 'Review',
    type: 'task',
    start: on(5),
    end: on(8),
    progress: 78,
    project: 'commerce',
    displayOrder: 6,
    styles: { indicatorColor: 'var(--color-palette-purple)' },
  },
];

export default function CustomBarContentDemo() {
  const [items, setItems] = useState(tasks);

  const handleExpanderClick = (task: Task<TaskMeta>) => {
    setItems((current) =>
      current.map((item) =>
        item.id === task.id
          ? { ...item, hideChildren: !item.hideChildren }
          : item,
      ),
    );
  };

  return (
    <div className="w-full">
      <Gantt<TaskMeta>
        tasks={items}
        viewMode="Day"
        headerHeight={70}
        rowHeight={50}
        gridColumnsWidth={100}
        handleWidth={8}
        onExpanderClick={handleExpanderClick}
        todayColor="var(--nui-primary-soft)"
        barWrapperClass="stroke-border fill-accent shadow"
        barProgressClass="fill-card stroke-border"
        customBarContent={(task) => (
          <div className="flex w-full items-center gap-1">
            <span
              className="h-2.5 w-2.5 shrink-0 rounded-full"
              style={{ backgroundColor: task.styles.indicatorColor }}
            />
            <div className="min-w-0 truncate select-none text-xs font-medium">
              {task.name}
            </div>
          </div>
        )}
      />
    </div>
  );
}

Bar Context Menu

Preview
Dark
import { useState } from 'react';
import Gantt from '@/components/composites/Gantt';
import Dropdown from '@/components/ui/Dropdown';
import type { Task } from '@/components/composites/Gantt';
import {
  PiPencilSimple,
  PiCalendarBlank,
  PiCopy,
  PiTrash,
} from 'react-icons/pi';

const now = new Date();
const on = (day: number) => new Date(now.getFullYear(), now.getMonth(), day);

const initialTasks: Task[] = [
  {
    id: 'discovery',
    name: 'Discovery',
    type: 'task',
    start: on(1),
    end: on(6),
    progress: 100,
    displayOrder: 1,
  },
  {
    id: 'design',
    name: 'Design',
    type: 'task',
    start: on(6),
    end: on(13),
    progress: 65,
    dependencies: ['discovery'],
    displayOrder: 2,
  },
  {
    id: 'checkpoint',
    name: 'Design checkpoint',
    type: 'milestone',
    start: on(13),
    end: on(13),
    progress: 0,
    dependencies: ['design'],
    displayOrder: 3,
  },
  {
    id: 'build',
    name: 'Build',
    type: 'project',
    start: on(13),
    end: on(24),
    progress: 20,
    displayOrder: 4,
  },
  {
    id: 'frontend',
    name: 'Frontend',
    type: 'task',
    project: 'build',
    start: on(13),
    end: on(20),
    progress: 30,
    displayOrder: 5,
  },
  {
    id: 'backend',
    name: 'Backend',
    type: 'task',
    project: 'build',
    start: on(17),
    end: on(24),
    progress: 10,
    dependencies: ['frontend'],
    displayOrder: 6,
  },
];

export default function BarContextMenuDemo() {
  const [tasks] = useState(initialTasks);

  return (
    <Gantt
      viewMode="Week"
      tasks={tasks}
      renderBarMenu={(task) => (
        <>
          <Dropdown.Item className="flex items-center gap-2">
            <PiPencilSimple aria-hidden="true" className="text-base" />
            <span>Edit {task.name}</span>
          </Dropdown.Item>
          <Dropdown.Menu
            placement="right-start"
            renderTitle={
              <span className="flex items-center gap-2">
                <PiCalendarBlank aria-hidden="true" className="text-base" />
                <span>Reschedule</span>
              </span>
            }
          >
            <Dropdown.Item>Next working day</Dropdown.Item>
            <Dropdown.Item>Next week</Dropdown.Item>
          </Dropdown.Menu>
          <Dropdown.Item className="flex items-center gap-2">
            <PiCopy aria-hidden="true" className="text-base" />
            <span>Duplicate</span>
          </Dropdown.Item>
          <Dropdown.Item variant="divider" />
          <Dropdown.Item className="flex items-center gap-2 text-destructive hover:bg-destructive-soft hover:text-destructive">
            <PiTrash aria-hidden="true" className="text-base" />
            <span>Delete</span>
          </Dropdown.Item>
        </>
      )}
    />
  );
}

Fixed Height

Preview
Dark
import { useState } from 'react';
import Gantt from '@/components/composites/Gantt';
import type { Task } from '@/components/composites/Gantt';

const now = new Date();
const on = (day: number) => new Date(now.getFullYear(), now.getMonth(), day);

const names = [
  'Kickoff',
  'Requirements',
  'Wireframes',
  'Visual design',
  'Frontend',
  'Backend',
  'Integration',
  'QA',
  'Beta',
  'Launch',
];

const tasks: Task[] = names.map((name, index) => ({
  id: `task-${index}`,
  name,
  type: 'task',
  start: on(1 + index * 2),
  end: on(4 + index * 2),
  progress: Math.max(0, 100 - index * 12),
  displayOrder: index + 1,
  ...(index > 0 ? { dependencies: [`task-${index - 1}`] } : {}),
}));

export default function FixedHeightDemo() {
  const [items] = useState(tasks);

  return (
    <div className="w-full">
      <Gantt viewMode="Week" tasks={items} ganttHeight={280} />
    </div>
  );
}

API

Gantt

PropDescriptionTypeDefault
tasksRows to render: tasks, milestones, and project groups. Required.Task<T>[]
columnsTask-list columns shown on the left. An empty array renders the default Name column; null hides the task list and shows the timeline only.Columns<T>[] | null[]
customBarContentRenders custom content inside each task bar's inset label area. Called for task-type rows only. Not the route for a menu — use renderBarMenu.CustomBarContent<T>
renderBarMenuMenu content for a bar's right-click menu. Return Dropdown.Item children. Applies to tasks, milestones, and project rows.(task: Task<T>) => ReactNode
viewModeTime scale of the timeline.ViewMode'Day'
viewDateScrolls the timeline so this date is in view.Date
scrollToTodayOnLoadScrolls to today on first render when today falls inside the data range. Ignored when viewDate is set.booleantrue
refImperative handle for scrolling the timeline. See GanttRef.Ref<GanttRef>
preStepsCountEmpty time columns kept before the first task.number1
localeLocale used for the header's day and month labels.string'en-GB'
rtlLays the chart out right-to-left.booleanfalse
classNameExtra classes on the Gantt wrapper element.string
rowHeightHeight of each row, in px.number50
gridColumnsWidthWidth of each time column, in px.number100
headerHeightHeight of the date header, in px.number70
ganttHeightFixed chart height in px; rows scroll vertically inside it. 0 grows to fit every row.number0
resizableTaskListLets the user drag the divider between the task list and the timeline.booleantrue
defaultTaskListWidthInitial task-list width in px. Omit to size to the columns' content.number
taskListMinWidthLower clamp when dragging the task list, in px.number160
taskListMaxWidthUpper clamp when dragging the task list, in px.number720
resizableColumnsLets the user drag the boundary between task-list columns.booleantrue
barFillPercentage of the row height a bar occupies.number60
barCornerRadiusCorner radius of bars, in px.number4
handleWidthWidth of the move/resize handles, in px.number8
barProgressClassSVG fill-* class for a task bar's progress fill.string'fill-primary'
barWrapperClassSVG fill-* class for a task bar's track.string'fill-[#b8c2cc]'
projectProgressClassProgress fill for project rows.string'fill-primary'
projectWrapperClassTrack fill for project rows.string'fill-[#b8c2cc]'
milestoneClassFill for milestone diamonds.string'fill-primary'
todayColorFill color of the current-day column.string'var(--nui-muted)'
offDaysWeekday indices shaded as non-working, 0 = Sunday. Only applies to Day and finer view modes. [] disables.number[][0, 6]
offDayClassSVG fill-* class for non-working columns. Non-working columns show a diagonal hatch by default; passing this replaces the hatch with a flat wash.string''
rollUpParentsDraws project rows spanning their children instead of their own dates. Dates only — a project's own progress stays as authored. Computed for display; never written to your data.booleanfalse
arrowClassExtra class on dependency arrows, which otherwise inherit border.string''
arrowIndentHorizontal indent of dependency arrows, in px.number20
timeStepSnap interval for drag and resize, in ms.number300000
TooltipContentComponent rendered in the hover/drag tooltip.FC<{ task: Task<T> }>StandardTooltipContent
pushDependentsMoves a dragged task's dependents with it — live during the drag, committed on drop. Only pushes later, never pulls earlier.booleanfalse
onDateChangeFires after a bar is moved or resized. Return false to reject the change — with pushDependents on, this reverts every task that moved, not just the dragged one. Providing this also enables the move and resize handles. The second argument is the task's direct successors when pushDependents is off (informational only); every task that actually moved when it's on.(task: Task<T>, children: Task<T>[]) => void | boolean | Promise<void | boolean>
onProgressChangeFires after the progress handle is dragged. Return false to reject. Providing this also enables the progress handle.(task: Task<T>, children: Task<T>[]) => void | boolean | Promise<void | boolean>
onExpanderClickFires when a project's expander is toggled. Providing this also hides the children of collapsed projects.(task: Task<T>) => void
onClickFires when a bar is clicked.(task: Task<T>) => void
onDoubleClickFires when a bar is double-clicked.(task: Task<T>) => void
onSelectFires when a bar gains or loses selection (focus).(task: Task<T>, isSelected: boolean) => void
onDeleteFires when Delete is pressed on a focused bar. Return truthy to remove it.(task: Task<T>) => void | boolean | Promise<void | boolean>
onCellClickFires when an empty timeline cell is clicked. Receives the row's task and the clicked column's start date. Providing this also enables the cell hover highlight and pointer cursor.(task: Task<T>, date: Date, event: MouseEvent) => void
onBarContextMenuFires on right-click of a bar, before the menu opens.(task: Task<T>, event: MouseEvent) => void

The move and resize handles only appear when onDateChange is set, and the progress handle only appears when onProgressChange is set. The chart keeps its own internal copy of the rows, so a bar stays where it is dragged even before the callback updates your data; return false from the callback to snap it back. With custom columns, the built-in expander button is not rendered — wire the cell's expander node to your own handler when you need clickable groups.

Types

type ViewMode =
  | 'Hour'
  | 'QuarterDay'
  | 'Half Day'
  | 'Day'
  | 'Week'
  | 'Month'
  | 'QuarterYear'
  | 'Year';
 
type TaskType = 'task' | 'milestone' | 'project';
 
type Task<T = object> = {
  id: string;
  type: TaskType;
  name: string;
  start: Date;
  end: Date;
  progress: number; // 0-100
  dependencies?: string[]; // ids of tasks this row follows
  project?: string; // parent project id
  hideChildren?: boolean; // collapse a project's children
  displayOrder?: number; // row sort order
  isDisabled?: boolean; // lock a row from editing
  styles?: { progressClass?: string; wrapperClass?: string };
} & T;
 
type Columns<T = object> = {
  header: string | ReactNode;
  cell: (task: Task<T> & { expander: ReactNode }) => string | ReactNode;
  width?: string | number;
};
 
type CustomBarContent<T = object> = (task: BarTask<T>) => ReactNode;
 
// BarTask is a Task extended with its computed bar geometry.
type BarTask<T = object> = Task<T> & {
  index: number;
  x1: number;
  x2: number;
  y: number;
  height: number;
  progressX: number;
  progressWidth: number;
  barChildren: BarTask<T>[];
};
 
type GanttRef = {
  today: () => void; // scroll today's column to the left edge (no-op if out of range)
  goTo: (date: Date) => void; // scroll the given date's column to the left edge (no-op if out of range)
  next: () => void; // scroll forward one viewport width
  prev: () => void; // scroll back one viewport width
  scrollToTask: (taskId: string) => void; // scroll the given task's start into view (no-op if unknown)
};