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
| Gesture | How it starts | What it writes |
|---|---|---|
| Move | press on the bar, away from the edge zones | startDate and endDate |
| Resize left | press inside the bar's left edge zone | startDate |
| Resize right | press inside the bar's right edge zone | endDate |
| Progress | press on the progress handle | progress |
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.
| Scale | One drag step | Pointer travel before the first step |
|---|---|---|
hour | 15 minutes | 15px |
day | 1 hour | 16px |
week | 6 hours | 27px |
month | 1 day | 16px |
quarter | 3 days | 12px |
year | 7 days | 14px |
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.
| Rank | Setting | Scope | Beaten by |
|---|---|---|---|
| 1 | allowMove / allowResize / allowProgressChange on the task | one task, one capability | nothing |
| 2 | readOnly on the task | one task, every capability | rank 1 |
| 3 | allowMove / allowResize / allowProgressChange prop on the chart | every task, one capability | ranks 1-2 |
| 4 | readOnly prop on the chart | every task, every capability | ranks 1-3 |
| 5 | nothing 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.
- A milestone is never resizable. It has no length to resize.
- 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,
minDatewins and the bar overhangsmaxDate.
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:
| Cause | What it usually is |
|---|---|
| Drift past 10px | a swipe, not a press |
| Release before 400 ms | a tap |
| The browser cancelling the pointer | a system gesture taking over |
| The row unmounting | the 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.
| Scale | A drawn range snaps to whole |
|---|---|
hour | hours |
day | hours |
week | days |
month | days |
quarter | months |
year | months |
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, whateverallowTaskCreatesays.allowTaskCreateon 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.
onBeforeTaskChangecovers moves, resizes and progress changes;onTaskCreatehas 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.
onTasksChangehands you an array and expects it back as thetasksprop. 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
weekscale, 24-31px ondayandmonth, 24-29px onhour, 24-27px onyear. 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.