@jaeungkim/gantt-chart

Editing tasks

Move, resize, progress, permissions, touch, and drawing a task

Someone grabs a bar, slides it three days later, and lets go. Between the press and the release the chart has to decide whether this task may be edited at all, how far it is allowed to travel, what it lands on, and what your app hears about it. Four pointer gestures write task data. A fifth draws a range on empty row space and writes nothing at all.

The four gestures

GestureHow it startsWhat it writes
Movepress on the bar, away from the edge zonesstartDate and endDate
Resize leftpress inside the bar's left edge zonestartDate
Resize rightpress inside the bar's right edge zoneendDate
Progresspress on the progress handleprogress

All four are on by default. The minimum wiring is a tasks array and an onTasksChange handler.

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

const initial: Task[] = [
  {
    id: 'design',
    name: 'Design',
    startDate: '2026-03-02T00:00:00Z',
    endDate: '2026-03-06T00:00:00Z',
    parentId: null,
    sequence: '1',
    progress: 40,
  },
  {
    id: 'build',
    name: 'Build',
    startDate: '2026-03-09T00:00:00Z',
    endDate: '2026-03-20T00:00:00Z',
    parentId: null,
    sequence: '2',
    progress: 0,
  },
];

export function App() {
  const [tasks, setTasks] = useState<Task[]>(initial);
  return <ReactGanttChart tasks={tasks} onTasksChange={setTasks} />;
}

A gesture that commits produces exactly one onTasksChange call, carrying the full array. That holds even when the gesture moved a whole subtree or pushed a chain of successors. A gesture that never travels far enough to move the bar commits nothing and calls nothing.

Nothing is written while the pointer is down. The bar you see moving is a pixel offset held separately from your data, and the array only changes on release.

Where the edge zones are

The edge zone is 8px wide for a mouse and 44px for touch or pen. A pen counts as touch throughout this page.

Those zones only exist on a bar that is wide enough to hold them: 24px of rendered width for a mouse, 132px for touch or pen. Below that the whole bar is the move handle and there is nothing to resize with. 132px is about 16.5 days at the quarter scale and about 33 days at the year scale, so a task loses its touch resize edges long before it looks small.

The edge zones are tested before the move zone. A task with allowMove: false and allowResize: true therefore resizes at its edges and does nothing at all in the middle.

The progress drag

The percentage is the pointer's position inside the bar's rendered rectangle, clamped to 0-100 and rounded to a whole percent. There is no step size on the pointer path.

The value is only sampled on pointer movement. Pressing the handle and releasing without moving changes nothing.

The two gestures part ways on cancellation. A progress drag that the browser cancels mid-gesture commits its last value. A bar move or resize cancelled the same way reverts and writes nothing.

A short drag is a click

A drag commits nothing until the pointer has travelled half a drag step. Below that the gesture ends with no write, and the trailing click still reaches onTaskClick. Once the bar has moved by a step, the click that ends the drag is swallowed. See Events and cancellable changes for the click callbacks themselves.

What a drag snaps to

A drag never snaps to an absolute grid line. The chart rounds the pointer's travel to a whole number of drag steps, then adds that many steps to the task's original dates.

A task that starts at 09:37 still starts at 09:37 after being dragged four days on the month scale. The time of day survives every drag. Nothing aligns a bar to midnight or to a tick boundary.

ScaleOne drag stepPointer travel before the first step
hour15 minutes15px
day1 hour16px
week6 hours27px
month1 day16px
quarter3 days12px
year7 days14px

The step count is rounded, so the bar jumps to the next step at half a step of pointer travel. The full per-scale configuration lives in The timeline.

The scale is captured when the gesture starts. Changing scale mid-drag does not re-unit the gesture in flight.

A resize always leaves at least one drag step of width. The step count itself is clamped, not just the preview, so a resize can never commit an end date before its start date. On a bar that is already narrower than one step the same clamp pushes the other way and widens it.

With a working-day calendar configured, the dragged edge additionally snaps forward to the next working day, and a move snaps both ends together so the bar keeps its length; see Scheduling.

Who may edit what

Every capability resolves on its own, per task, through four rungs. The first rung that has a value wins, and nothing below it is consulted.

RankSettingScopeBeaten by
1allowMove / allowResize / allowProgressChange on the taskone task, one capabilitynothing
2readOnly on the taskone task, every capabilityrank 1
3allowMove / allowResize / allowProgressChange prop on the chartevery task, one capabilityranks 1-2
4readOnly prop on the chartevery task, every capabilityranks 1-3
5nothing set--

Rank 5 means the gesture is allowed. There is no explicit default of true anywhere; the observable default is "allowed unless something says otherwise".

Two consequences are easy to get backwards. A task's readOnly: true beats a permissive allowMove prop on the chart, because rank 2 sits above rank 3. And a task's own allowMove: true punches through both readOnly settings, because rank 1 sits above everything.

minDate and maxDate resolve through a shorter chain of their own: the task's value if it has one, otherwise the chart's. The two ends resolve independently, so a task can take its floor from itself and its ceiling from the chart.

A blocked gesture is not a gesture that fails. No drag starts at all: the press is a no-op. The affordances go with it, so a bar that cannot be resized hides its resize grips and a bar whose progress is fixed hides its handle. The progress fill stays visible as a read-only readout. The full type is in GanttInteractionConfig.

Two rules no flag turns back on

Both of these are applied in front of the resolution chain, so no combination of flags at any level re-enables them.

  1. A milestone is never resizable. It has no length to resize.
  2. A summary row is never resizable and its progress is never draggable. Both ends and the percentage are rolled up from its children, so an edit would snap straight back on the next render.

Moving a summary row is still allowed, and it carries its whole subtree. That is the only gesture that writes more than one task's dates without the scheduling engine being involved.

Summary rows only exist when hierarchy is on, so the second rule does not apply to a flat chart; see Task list and hierarchy. Setting readOnly on a summary still freezes it completely, move included.

A frozen chart with one editable bar

readOnly on the chart is rank 4, so a single task can opt back in.

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

const tasks: Task[] = [
  {
    id: 'plan',
    name: 'Plan',
    startDate: '2026-03-02T00:00:00Z',
    endDate: '2026-03-06T00:00:00Z',
    parentId: null,
    sequence: '1',
  },
  {
    id: 'review',
    name: 'Review',
    startDate: '2026-03-09T00:00:00Z',
    endDate: '2026-03-13T00:00:00Z',
    parentId: null,
    sequence: '2',
    // rank 1 - beats the chart's readOnly below
    allowMove: true,
    allowResize: true,
  },
];

export function ReviewBoard({ onChange }: { onChange: (tasks: Task[]) => void }) {
  return <ReactGanttChart tasks={tasks} readOnly onTasksChange={onChange} />;
}

The same shape covers the narrower case. A task with readOnly: true and allowProgressChange: true cannot be moved or resized, but its progress handle still drags.

Drag bounds

minDate and maxDate fence a bar into a window. Both take a UTC ISO string, and a string without a zone is read as a UTC wall clock: minDate="2026-03-01" means midnight UTC, not local midnight.

What the bar does at the fence depends on the gesture.

  • Resize left. The start date is clamped into the window, then pulled back so it stays at least one drag step before the end date.
  • Resize right. The end date is clamped into the window, then pushed out so it stays at least one drag step after the start date.
  • Move. Both ends travel together and the bar keeps its length. When the bar is longer than its own window, minDate wins and the bar overhangs maxDate.

The bar visibly stops on the bound rather than overshooting and springing back, because the pixel offset is re-measured from the clamped dates every frame. The date handed to onTasksChange is the bound itself.

Moving a summary row applies one shared delta to the whole subtree, and the tightest bound among the moving tasks wins. A descendant's maxDate therefore stops its parent's drag. The delta never overshoots the requested direction, so a task that already sits outside its window refuses to move further instead of being yanked back inside.

The one-step minimum width is applied after the bound, so a bar whose window has already been passed stays a valid bar instead of folding over.

A working-day snap runs before the clamp. A bar pinned to its bound can therefore end up on a non-working day.

Bounds cover bar moves and resizes only. They are not read by the progress drag, by the draw-a-task gesture, by the automatic rescheduling cascade, or by row reordering. A successor pushed by schedulingPolicy can land outside its own maxDate.

Touch

A mouse press starts a gesture immediately. A touch or pen press has to rest on the bar for 400 ms before the drag lifts.

During that wait the finger may drift up to 10px. Drift is measured as horizontal plus vertical distance from the landing point, so 6px across and 6px down is 12px and cancels the press even though it is 8.5px on screen.

Until the press lifts, the bar scrolls with the timeline like any other part of the page. That is what makes a swipe across a dense chart a scroll and not an accidental drag. Once the press has lifted, touch scrolling is suppressed for the duration of the gesture.

The drag starts from where the finger settled, not from where it landed. Four things cancel a pending lift:

CauseWhat it usually is
Drift past 10pxa swipe, not a press
Release before 400 msa tap
The browser cancelling the pointera system gesture taking over
The row unmountingthe row scrolled out of view under the finger

A bar only answers the primary pointer's left button. A second finger and a right-click are dropped before anything is armed, so neither starts a gesture nor cancels the one already pending. The progress handle does not share that guard.

On a device with no hover state that handle is a 10px dot with a 44px hit area, and it stays visible because nothing can hover to reveal it.

Auto-scrolling at the viewport edge

autoScrollOnDrag is true by default. Only the literal false turns it off.

It fires when the pointer comes within 48px of the timeline viewport's left or right edge during a bar move or resize. The left edge is the timeline's own, measured past the task list pane, so the 48px is counted from where the pane ends and not from the window. Dragging back over the pane is past the edge, not short of the zone, so it scrolls left at full speed.

The speed ramps linearly from zero at the boundary of the zone to 22px per frame at the edge itself, and is capped there. On a narrow timeline the zone shrinks to half the viewport width, so the two zones never overlap.

Each scrolled frame re-applies the move from the last known pointer position. The drag keeps advancing while the pointer is held still at the edge.

The auto-scroll is undone and the viewport smooth-scrolls back to where it started whenever the gesture is discarded: a cancelled drag, a rejected one, and a drop that never travelled a whole step. A committed drop leaves the viewport where it scrolled to.

Only bar moves and resizes auto-scroll. Dragging progress or drawing a task near the edge does nothing.

Drawing a new task

Press on empty row space in the timeline, drag sideways, and release. A dashed ghost follows the pointer, and on release the chart hands your app the range that was drawn.

The press has to land on the empty content, not on a bar, an arrow, or a handle. A mouse starts drawing immediately; touch and pen need the same 400 ms long press as a bar drag.

Only horizontal distance counts. The row is fixed at the moment of the press, so a diagonal drag still draws on the row it started on. A drag shorter than 4px proposes nothing.

Escape cancels a draw in progress. It has no effect on a bar or progress drag.

Drawn ranges snap to ticks, not to drag steps

This is the one place where the snapping unit differs from the rest of the page. A drawn range expands outwards to whole ticks of the current scale.

ScaleA drawn range snaps to whole
hourhours
dayhours
weekdays
monthdays
quartermonths
yearmonths

So on the year scale a bar drags in 7-day steps but draws as a whole month. A range that stays inside a single tick still comes out one tick long.

Drag bounds are not applied. A drawn range can start before minDate.

The draft, and what the chart does with it

Nothing. The chart creates no task, writes no array, and fires no onTasksChange. It calls onTaskCreate with a draft and stops there. The new row appears only when your app builds a task and passes a new tasks array back in.

GanttTaskDraft has three fields: startDate and endDate as UTC ISO strings snapped to the scale, and rowTaskId, the id of the task whose row the range was drawn on. rowTaskId is null on a group header row and below the last row. On a swimlane row holding several tasks it is the first task's id only; see Grouping and swimlanes. The full type is in GanttTaskDraft.

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

export function PlanBoard({ initial }: { initial: Task[] }) {
  const [tasks, setTasks] = useState<Task[]>(initial);

  const handleTaskCreate = ({ startDate, endDate, rowTaskId }: GanttTaskDraft) => {
    const name = window.prompt('Task name');
    if (!name) return; // nothing is added - the chart adds nothing itself

    setTasks((current) => [
      ...current,
      {
        id: crypto.randomUUID(),
        name,
        startDate, // UTC ISO, snapped to whole ticks of the current scale
        endDate,
        parentId: rowTaskId, // null on a group header row
        sequence: `${current.length + 1}`,
      },
    ]);
  };

  return (
    <ReactGanttChart
      tasks={tasks}
      onTasksChange={setTasks}
      onTaskCreate={handleTaskCreate}
    />
  );
}

Turning drawing off

allowTaskCreate is chart-wide. It resolves through two rungs only: its own value, then !readOnly. Task has no allowTaskCreate field, and putting one there is ignored.

[!IMPORTANT] The gesture also needs onTaskCreate. Without that callback there is no crosshair cursor, no ghost, and no gesture at all, whatever allowTaskCreate says. allowTaskCreate on its own does nothing.

Drawing is also unreachable in a chart with no tasks, because there are no rows to draw on.

Limits

Pointer editing writes dates and progress on existing tasks. Everything else is somewhere else, or is yours to do.

  • The chart never creates a task. Drawing proposes a draft; your app decides. The chart never deletes one with the pointer either - deletion exists only in the keyboard layer.
  • A drawn task cannot be vetoed. onBeforeTaskChange covers moves, resizes and progress changes; onTaskCreate has no reject path. See Events and cancellable changes.
  • A bar cannot be dragged to another row. There is no vertical bar dragging. Changing a row's parent or position is Reordering rows.
  • There is no multi-select drag. Exactly one bar is grabbed at a time. A summary carrying its subtree is the only many-task drag.
  • Drag bounds are not a scheduling constraint. The rescheduling cascade ignores them entirely; see Scheduling.
  • Nothing snaps to midnight or to a tick. Drags are relative step shifts, so the original time of day is preserved. The working-day snap is the one exception, and it too shifts by whole days and leaves the time of day alone.
  • Persistence is yours. onTasksChange hands you an array and expects it back as the tasks prop. Nothing is stored, nothing is sent anywhere.
  • Validation is yours. The chart enforces its own bounds and its minimum bar length, and nothing else. Business rules belong in onBeforeTaskChange.
  • Drawing dependency arrows is a separate gesture with its own permission flags; see Dependencies.
  • A bar narrower than one drag step grows when you grab its edge. The shrink clamp has nothing left to give, so the first pointer movement adds a step in the direction you grabbed, with no travel, and that commits. With a mouse it is reachable between 24px and one drag step of rendered width - 24-53px on the week scale, 24-31px on day and month, 24-29px on hour, 24-27px on year. Touch needs 132px before the edges exist at all, so it never hits this.
  • The progress handle is less guarded than the bar. It has no primary-pointer or left-button check, so a right-button press arms it, and it does not filter by pointer id, so a second finger's movement rewrites the percentage of the gesture already running.

Move, resize and progress each have a keyboard equivalent, running through the same permission chain and the same drag bounds; the key map is in Keyboard and screen readers. Drawing a new task on empty row space has none, and neither does drawing a dependency arrow or reordering a row.

Next: Dependencies, which covers linking two bars and what the chart rejects before your handler ever sees it.

On this page