@jaeungkim/gantt-chart

Task data

The `Task` shape, date handling, and how the `tasks` prop is compared

Your rows come out of a database, and every field name is different from the one the chart wants. Before you map anything you need to know which fields are mandatory, which are inert until you turn a prop on, and what happens when a value arrives malformed. The chart runs no validation at all, so a wrong field does not raise an error — it draws something wrong instead. This page is the contract for the array you hand to tasks.

The minimum that renders

Six fields are required on every task. Everything else is optional.

// src/TaskDataExample.tsx
import { ReactGanttChart, type Task } from '@jaeungkim/gantt-chart';
import '@jaeungkim/gantt-chart/style.css';

const tasks: Task[] = [
  {
    id: 'design',
    name: 'Design',
    startDate: '2025-06-01',
    endDate: '2025-06-10',
    parentId: null,
    sequence: '1',
  },
  {
    id: 'build',
    name: 'Build',
    startDate: '2025-06-10',
    endDate: '2025-06-24',
    parentId: null,
    sequence: '2',
    progress: 40,
    dependencies: [{ targetId: 'design', type: 'FS' }],
  },
];

export function TaskDataExample() {
  return <ReactGanttChart tasks={tasks} height={400} />;
}

tasks itself is optional on the component. Omitting it, or passing [], renders an empty chart.

Task fields

FieldTypeRequiredDefaultMeaning
idstringyesIdentity. Dependencies, roll-up and undo patches resolve through it. Uniqueness is never checked.
namestringyesLabel on the bar, in the task list cell, and in the ARIA label. Rendered as text.
startDatestringyesLeft edge of the bar. Any string dayjs can parse; see Dates and time zones.
endDatestringyesRight edge of the bar. Ignored for a milestone, with one exception below.
parentIdstring | nullyesId of the parent task, or null for a root. Only read when hierarchy is on.
sequencestringyesDot-separated numbers, '1', '2.1', '2.10'. Decides row order.
type'task' | 'milestone'no'task''milestone' draws a diamond at startDate.
progressnumbernononePercentage 0–100. Omitted means no progress fill.
colorstringnotheme tokensAny CSS color. The progress fill and the hover shade are derived from it.
classNamestringnononeExtra class on the bar element and on the task list row.
lanestringnoown rowTasks sharing a lane are drawn side by side on one row. An empty string counts as absent.
dependenciesTaskDependency[]no[]The predecessors this task waits on.
readOnlybooleannononeBlocks every gesture on this task.
allowMovebooleannononeAllows or blocks dragging the bar sideways.
allowResizebooleannononeAllows or blocks dragging either edge.
allowProgressChangebooleannononeAllows or blocks the progress handle.
allowLinkCreatebooleannononeAllows or blocks starting a dependency drag from this task.
allowLinkDeletebooleannononeAllows or blocks deleting a dependency this task owns.
minDatestringnothe chart's minDateEarliest date a drag may land on.
maxDatestringnothe chart's maxDateLatest date a drag may land on.
manuallyScheduledbooleannofalseThe scheduling engine never moves this task. It still constrains its successors.
baselineStartstringnononePlanned start snapshot, drawn as a thin bar under the live one. Alone, it draws a single point.
baselineEndstringnononePlanned end snapshot. Without baselineStart it is ignored entirely.

The five allowX flags and readOnly resolve most-specific-first, task.allowX before task.readOnly before the chart's config; see Editing tasks. minDate and maxDate do not use that chain — the task's value replaces the chart's when present. color and className and the CSS variables a colored bar sets are covered in Custom rendering. lane is covered in Grouping and swimlanes.

Fields that do nothing on their own

Three fields are read only under a prop. lane and the two baseline fields are not.

FieldWhat it needs
parentIdhierarchy — without it no tree is built, depth comes from sequence, and nothing is a summary row. See Task list and hierarchy.
manuallyScheduledschedulingPolicy set to something other than 'off'. See Scheduling.
TaskDependency.lagschedulingPolicy, or criticalPath. A lag: 5 link draws exactly like lag: 0 on a chart with neither.
lanenothing. Packing runs with or without groupBy; with groupBy on, a lane packs inside its own group only. See Grouping and swimlanes.
baselineStart / baselineEndnothing. A baseline renders as soon as baselineStart is present, and it widens the timeline range. There is no prop gate and no way to switch it off other than removing the field. See Scheduling.

manuallyScheduled does not stop the user dragging the bar. It only stops the scheduling engine moving it.

TaskDependency fields

A dependency lives on the successor and names its predecessor.

FieldTypeRequiredDefaultMeaning
targetIdstringyesThe predecessor's id.
type'FS' | 'SS' | 'FF' | 'SF'yesFirst letter is the predecessor's end, second is the successor's end.
lagnumberno0Signed days. Positive is lag, negative is a lead. Working days when workingCalendar is on.

What the four types mean for scheduling, and how arrows are drawn and deleted, is in Dependencies.

Two entries are dropped without a warning: one whose targetId is not in tasks, and one whose targetId is the task's own id. An unrecognized type skips that arrow, and logs a console.warn once per distinct value in a development build only. In a production bundle the arrow is missing with no signal at all.

Dates and time zones

The chart parses every date through a dayjs instance locked to UTC mode. The timezone plugin is not loaded, so there is no per-viewer local mode to opt into.

InputWhat is displayed
'2025-06-01T09:00:00Z'that instant at UTC clock time, 09:00
'2025-06-01T18:00:00+09:00'the same instant, also 09:00
'2025-06-01T09:00'read as a UTC wall clock, so 09:00 in every viewer's time zone
'2025-06-01'2025-06-01T00:00:00.000Z, UTC midnight

A bare YYYY-MM-DD is therefore safe. It lands on the day it names for every viewer, in Seoul and in Los Angeles alike.

A UTC ISO string is the shape the chart emits, but it is not a requirement on input. Any string dayjs can parse works, and a string without a zone is read as a UTC wall clock rather than rejected or shifted. Because the grid is UTC, every hour cell is 60 minutes wide and every day cell is the same width, even across a local 23- or 25-hour daylight-saving day.

There is no date validation on tasks. An unparseable startDate does not hide the row and does not throw. Its bar starts at the left edge of the timeline and spans the whole width. Markers and range bands are validated, so the same bad string is silently dropped as a marker and drawn full-width as a task.

Every value the chart writes back is serialized with toISOString(). A task you passed as '2025-06-01' comes back as '2025-06-01T00:00:00.000Z' after the first drag. That matters for the diff rule below.

sequence and parentId

sequence decides row order, always. parentId decides nesting, and only when hierarchy is on. Nothing reconciles the two.

Segments are compared numerically, left to right. '1.10' sorts after '1.2', not before it. A missing segment counts as 0, so '1' and '1.0' compare equal, and ties keep the order the array was in. The input array is copied before sorting, never mutated.

A segment that is not a number becomes 0. 'abc', '1a' and '' all sort as if they were '0', ahead of '1', with no warning. A duplicated sequence is not an error either — the two rows tie, and the array order breaks the tie.

[!WARNING] A missing sequence throws. sequence.split('.') runs on undefined inside the layout effect that builds the timeline, and the whole chart fails to render. TypeScript marks the field required, so this only bites hand-built JSON and data mapped without types.

With hierarchy off, indentation depth is the number of dots in sequence, so '2.1.1' renders two levels in. With hierarchy on, depth comes from the parentId chain and sequence only orders. Incoherent input renders incoherently: a child whose sequence sorts above its parent is drawn above its parent.

The library rewrites sequence in exactly one situation. A row reorder renumbers every task depth-first from the resulting tree, and writes parentId on the moved task only. Persist the whole array after a reorder, not only the row that moved. See Reordering rows.

Milestones

A milestone is a task whose type is the exact string 'milestone'. The comparison is strict, so 'Milestone' and 'MILESTONE' are ordinary tasks.

A milestone occupies a single point at startDate — its computed bar width is 1px — and draws a 16px diamond centered there. It reports zero duration to scheduling and to the critical path. It is never resizable, and no capability flag can turn that back on. It renders no progress fill. Roll-up counts it at startDate, and lane packing treats startDate as its end.

The one gap in that list is the keyboard. A milestone that already carries a numeric progress can still have it stepped from the keyboard: the value changes, onTasksChange fires, and nothing on screen moves. Only summary rows are blocked from progress editing structurally.

The sharp edge is endDate. Rendering ignores it, roll-up ignores it, and scheduling ignores it — but the function that fits the timeline to the data reads endDate unconditionally, for every task.

import type { Task } from '@jaeungkim/gantt-chart';

// This milestone renders as a diamond on 2025-02-01,
// and stretches the timeline out to 2030 with nothing drawn there.
const milestone: Task = {
  id: 'launch',
  name: 'Launch',
  type: 'milestone',
  startDate: '2025-02-01',
  endDate: '2030-01-01',
  parentId: null,
  sequence: '3',
};

The same unconditional read powers a marker's warnOnOverrun check, so a milestone can trip an overrun warning on a date it never renders at. Set endDate equal to startDate on every milestone and neither problem exists. Roll-up progress is the one other place a milestone's endDate counts, as the duration weight for a child.

progress

progress is a number from 0 to 100. Out-of-range values are clamped rather than rejected.

Value passedValue used
4242
33.733.7 — no rounding
-100
150100
Infinity100
-Infinity0
undefinednone — no fill drawn
NaNnone
'50'none — a string is dropped, not coerced

The last row is the one that catches people. A percentage that arrived from an API as a string is not parsed. The bar renders with no fill and nothing reports a problem.

Rounding happens only where the chart produces a value. Dragging the progress handle emits a whole number, and a summary row's rolled-up percentage is rounded. A fractional value you pass in stays fractional.

A parent's own progress always wins over the value rolled up from its children; the roll-up formula is in Task list and hierarchy.

Updating the tasks prop

You drag a bar, your onTasksChange handler writes to state, state flows back down into tasks, and the bar snaps back to where it was. Or worse: it stays put, but your undo stack is empty. Both come from the same mechanism.

The chart compares the incoming tasks array against the last one it accepted using JSON.stringify on both sides. There is no structural diff and no field-wise comparison. If the two strings match, the update is discarded and the chart keeps its own state, which is what stops your echo from reverting the drag you just made. If they differ, the chart replaces its data and wipes both undo stacks, because a different string means the host replaced the data.

String equality has two consequences that a structural diff would not have.

Key order matters. These two objects describe the same task and produce different strings.

JSON.stringify({ id: 'a', name: 'Design' }); // '{"id":"a","name":"Design"}'
JSON.stringify({ name: 'Design', id: 'a' }); // '{"name":"Design","id":"a"}'

undefined-valued keys are invisible. { id: 'a', progress: undefined } and { id: 'a' } serialize identically, so switching between them is not a change at all.

Two more follow from the same code. Extra properties of your own count toward the comparison, and mutating a task in place while keeping the same array identity is invisible — the comparison never runs.

A wrong update and a right one

This handler rebuilds every task with a different key order and re-formats the dates. Every string differs from what the chart emitted, so every gesture clears the undo history it just recorded.

// wrong
import { ReactGanttChart, type Task } from '@jaeungkim/gantt-chart';

function Wrong({ tasks, setTasks }: {
  tasks: Task[];
  setTasks: (next: Task[]) => void;
}) {
  return (
    <ReactGanttChart
      tasks={tasks}
      onTasksChange={(updated) => {
        setTasks(
          updated.map((task) => ({
            name: task.name,
            id: task.id,
            startDate: task.startDate.slice(0, 10), // '2025-06-01T00:00:00.000Z' -> '2025-06-01'
            endDate: task.endDate.slice(0, 10),
            parentId: task.parentId,
            sequence: task.sequence,
          }))
        );
      }}
    />
  );
}

Hand back exactly what you were given. The array is already a new array, and every object in it is already in the shape the chart emitted.

// right
import { ReactGanttChart, type Task } from '@jaeungkim/gantt-chart';

function Right({ tasks, setTasks }: {
  tasks: Task[];
  setTasks: (next: Task[]) => void;
}) {
  return (
    <ReactGanttChart
      tasks={tasks}
      onTasksChange={(updated) => setTasks(updated)}
    />
  );
}

Reshape for your API inside the save call, not on the way back into the prop. When you do need to change the data yourself — inserting a task, deleting one — build the new array from the one the chart last emitted, and accept that the undo history resets. What one undo step covers, and how onBeforeTaskChange fits in, is in Events and cancellable changes.

Limits

The chart reads your array. It does not police it.

  • No validation of any kind. No required-field check, no date parsing check, no id-uniqueness check, no range check on progress, no membership check on DependencyType. The only runtime diagnostic in the whole model is one development-only console.warn for an unknown dependency type.
  • Duplicate ids are never detected. Every array entry still gets a row, but every lookup keyed by id keeps the last occurrence. Dependencies, roll-up and undo patches resolve to the wrong task. Deduplicate before you pass the array in.
  • No normalization on input. Dates are not re-serialized, sequence is not renumbered, and parentId is not rewritten. An orphan or a cycle is treated as a root when the tree is built, but the field itself is left exactly as you wrote it.
  • No local time zone mode. UTC is hardcoded. Convert to and from local wall-clock time in the host app if your users need it.
  • The chart never adds or removes tasks. Drawing a new task proposes a draft through onTaskCreate, and the host supplies id, parentId and sequence. A change in row count is not invertible by a field patch, so it clears the undo history instead of adding a step.
  • There is no metadata field. Extra properties survive because the type is structural, but they are untyped and they count toward the tasks diff.
  • TaskTransformed is output only. The chart hands it to your renderers and column functions; tasks only ever accepts Task. Its fields are listed in Task.
  • Nothing reconciles sequence with parentId. A child whose sequence sorts above its parent stays drawn above it, until the first row reorder renumbers the whole array.

Nothing is written back into the objects you passed. Every change leaves through onTasksChange as a new array, and your own copy is untouched until you replace it.

Next: Task list and hierarchy — turning parentId into summary rows, and choosing the columns beside them.

On this page